Compare commits

...

40 Commits

Author SHA1 Message Date
Sanjai Kumar
536b7393db refactor: update deprecation messages for Presets and Post Response Vars (#6230)
* refactor: update DeprecationWarning component to accept children and enhance deprecation messages for Presets and Post Response Vars

* refactor: update DeprecationWarning component to use props for feature names and links, enhancing deprecation messages across various components
2025-11-28 15:29:22 +05:30
Sanjai Kumar
172479edad feat: Add deprecation warnings for Presets and Post Response Vars (#6212)
* feat: add deprecation warnings for presets and post response vars

* refactor: update deprecation warnings for presets and post response vars to include version
2025-11-26 21:14:50 +05:30
sanish chirayath
486b91894c fix: fetching reflection adds draft state in gRPC (#6218) 2025-11-26 19:51:34 +05:30
sanish chirayath
ca8ef36f9f fix: grpc messages vanishes after save if the body contains variables (#6216) 2025-11-26 18:50:10 +05:30
Chirag Chandrashekhar
7ed474c8ba fix: add Error constructors to NodeVM context for jsonwebtoken tests (#6209)
When jsonwebtoken throws errors inside the NodeVM context, those errors
were instances of the VM's isolated Error class, which caused
instanceOf(Error) checks in tests to fail.

By adding Error constructors (Error, TypeError, ReferenceError,
SyntaxError, RangeError) from the global scope to the scriptContext,
errors thrown by jsonwebtoken and other modules now use the same Error
class that tests check against, ensuring instanceOf checks pass correctly.

This fixes jsonwebtoken test failures when using the NodeVM runtime.
2025-11-26 17:57:11 +05:30
Pooja
b0405b1e1a fix: variable name validation in brunoVarInfo (#6203)
* fix: variable name validation in brunoVarInfo
2025-11-26 00:50:57 +05:30
Bijin A B
c2d000e805 fix: disallow prompts with leading or trailing spaces (#6201) 2025-11-25 16:53:05 +05:30
sanish chirayath
6aaccabc04 fix: openapi import fails when requestbody content is empty (#6200) 2025-11-25 16:19:57 +05:30
Sid
daf23c9e2d feat: add coding standards documentation (#6141) 2025-11-25 13:30:26 +05:30
Pooja
f952688032 improve: add var functions (#6175)
* improve: add var functions

* Update packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-11-21 21:12:06 +05:30
Bijin A B
f429fa94e3 fix(security): prototype pollution vulnerability in js-yaml (#6168) 2025-11-21 17:42:31 +05:30
Pooja
fb420fcea4 fix: preserve draft state when creating variables via varInfoToolbar (#6152)
* fix: preserve draft state when creating variables via varInfoToolbar
2025-11-21 15:47:23 +05:30
Anoop M D
cc3d6a961a Merge pull request #6169 from pooja-bruno/fix/reduce-font-size-of-tabs-text
fix: reduce font size of tab test
2025-11-21 13:41:31 +05:30
pooja-bruno
27c37192b2 fix: reduce font size of tab test 2025-11-21 12:59:27 +05:30
Bijin A B
faa2ef5de2 Merge pull request #6162 from bijin-bruno/fix/bruno-to-postman-converter
fix: sync bruno to postman converter with enterprise edition
2025-11-20 19:13:33 +05:30
Sanjai Kumar
c05d56fd21 Improve "Close All Collections" community PR (#5994)
* refactor: move CollectionsBadge to a dedicaced folder

Co-authored-by: Jérémy Munsch <github@jeremydev.ovh>
2025-11-19 22:48:30 +05:30
Pragadesh-45
b4d19ab8ca fix: push event only if exec has content (#6121) 2025-11-19 12:09:18 +05:30
Siddharth Gelera (reaper)
0cedf48e68 feat: encapsulate tab boundaries into a hook for managing pane dimensions (#5878)
* feat: implement useTabPaneBoundaries hook for managing pane dimensions

* fix: replace hardcoded divisor with constant in useTabPaneBoundaries

* chore: un-needed event calls

* fix: remove redundant import of sendRequest

* update main rediff
2025-11-19 11:30:39 +05:30
Pooja
4e7bc1a351 fix: prevent import failure for Postman collections with missing response headers (#6129) 2025-11-19 07:53:18 +05:30
Sid
9d3c8b2401 feat: Allow ctrl/cmd + click to open URLs present in codemirror (#5930)
* feat: Allow ctrl/cmd + click to open URLs present in codemirror editors (#5160)

* Allow ctrl/cmd + click to open URLs

* fix for when user does cmd+tab, then comes back without it

---------

Co-authored-by: Sid <siddharth@usebruno.com>

* Feature/cmd click on links (#5927)

fix: clean up whitespace and formatting in linkAware functions

fix rediff

Feature/cmd click on links (#6132)

* Allow ctrl/cmd + click to open URLs

* fix for when user does cmd+tab, then comes back without it

* refactored the community contribution to match Autocomplete's implementation

* updated the code to resolve issues caused during merge conflict resolution with the use of makeLinkAware

* fix: updated the code to use lodash's debounce and removed redundant undefined checks

* fix: correct debouncing test expectation in linkAware.spec.js

The test was incorrectly expecting 3 setTimeout calls when debouncing
should only result in one active timeout. Updated the test to verify
debouncing behavior correctly by checking that setTimeout is called
with the correct delay, and that only one execution happens after
the debounce delay.

* fix: fixed merge issues in linkAware.js

* fix: fixed CodeMirror assignment to this.editor

* fix: formatting fixes

* fix: formatting fix

---------

Co-authored-by: abansal21 <37215457+abansal21@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com>

---------

Co-authored-by: Arun Bansal <37215457+abansal21@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com>
2025-11-18 17:56:37 +05:30
Sid
39dfd8d360 Feature/cmd click on links (#5927)
fix: clean up whitespace and formatting in linkAware functions

fix rediff

Feature/cmd click on links (#6132)

* Allow ctrl/cmd + click to open URLs

* fix for when user does cmd+tab, then comes back without it

* refactored the community contribution to match Autocomplete's implementation

* updated the code to resolve issues caused during merge conflict resolution with the use of makeLinkAware

* fix: updated the code to use lodash's debounce and removed redundant undefined checks

* fix: correct debouncing test expectation in linkAware.spec.js

The test was incorrectly expecting 3 setTimeout calls when debouncing
should only result in one active timeout. Updated the test to verify
debouncing behavior correctly by checking that setTimeout is called
with the correct delay, and that only one execution happens after
the debounce delay.

* fix: fixed merge issues in linkAware.js

* fix: fixed CodeMirror assignment to this.editor

* fix: formatting fixes

* fix: formatting fix

---------

Co-authored-by: abansal21 <37215457+abansal21@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com>
2025-11-18 17:44:24 +05:30
Arun Bansal
460832f3ed feat: Allow ctrl/cmd + click to open URLs present in codemirror editors (#5160)
* Allow ctrl/cmd + click to open URLs

* fix for when user does cmd+tab, then comes back without it

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-11-18 17:43:56 +05:30
Sanjai Kumar
50442d960d feat: enhance HTML report generation by including environment name (#6055) 2025-11-18 16:09:57 +05:30
Abhishek S Lal
2ac41806a2 fix: update result structure to use 'name' instead of 'suitename' in JUnit output (#6120)
* fix: update result structure to use 'name' instead of 'suitename' in JUnit output
2025-11-18 12:31:41 +05:30
Bijin A B
e9111c0529 Merge pull request #6104 from bijin-bruno/feature/prompt-vars-extended 2025-11-17 22:02:11 +05:30
Bijin Bruno
48a09f6f50 feat: enhance support for prompt variables 2025-11-17 20:12:20 +05:30
Bijin A B
e613e4cbcd Merge pull request #5975 from abhishek-bruno/fix/reorder-item-when-deleting-v2
Refactor: Enhance Request Item sequencing
2025-11-17 16:19:00 +05:30
Pooja
4631eda281 Merge pull request #6069 from pooja-bruno/feat/add-edit-variable-in-place
feat: edit variable in place
2025-11-17 16:13:09 +05:30
Abhishek S Lal
3f7ab31b2b refactor: enhance deleteItem action to handle item reordering after deletion 2025-11-17 16:05:33 +05:30
Bijin A B
27a7b623c7 Merge pull request #6039 from sanish-bruno/feat/openapi-examples
fix: import multiple types of example formats from openapi
2025-11-17 14:12:35 +05:30
sanish-bruno
95bc670d8c fix: regex 2025-11-17 13:53:24 +05:30
sanish-bruno
6d8f428140 refactor 2025-11-17 13:53:24 +05:30
sanish-bruno
ed18cb6d90 fix: improve logic for and tests 2025-11-17 13:53:24 +05:30
sanish-bruno
bb83fbfb9d fix: add schema based example 2025-11-17 13:53:24 +05:30
Sid
ddfdeda4d6 Merge pull request #6074 from usebruno/feature/http-stream-internal
Feature: HTTP Streaming
2025-11-17 13:44:53 +05:30
skewnart
adb0b90457 fix: reorder request and directory when deleting item 2025-11-17 13:40:13 +05:30
Pooja
8c7888533a feat: support newlines in headers, params, and variables (#5795)
* feat: support newlines in headers, params, and variables

* add: collectin unit test

* fix: assertion and additional header multiline

* fix: assert

* rm: useEffect for header validation

* rm: comments

* fix: already encoded url

* rm: new line changes

* handle new line in url

* fix: lint error

* add: unit test for multi line test

* change: unit test

* mv: functions in util

* fix: drag icon position

* improve: arrow height

* improvements

* rm: getKeyString from assert

* fix: single line editor

* fix: import MultiLineEditor

* import getKeyString and getValueUrl

* add: getTableCell in utils

* rm: multiline key logic

* fix

* mv: getTableCell in locators.ts
2025-11-17 13:27:00 +05:30
Chirag Chandrashekhar
2be602d16c Feature/prompt save before collection close (#6062)
* added confirmation dialog before collection close for items in draft state

* chore: lint fix

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-11-17 12:09:12 +05:30
Chirag Chandrashekhar
8ec1925b9f feat: add variable interpolation support for WebSocket requests (#6064)
* feat: add variable interpolation support for WebSocket requests

- Add WebSocket body interpolation in interpolateVars function
- Interpolate URL, headers, and all messages in request.body.ws array with full variable context
- Refactor sendWsRequest to use main process preparation (removes duplication)
- Add mode property to wsRequest object for proper request type detection
- Ensure consistent variable precedence matching HTTP/gRPC requests
- Centralize all interpolation logic in main process via prepareWsRequest

* Add Playwright tests for WebSocket variable interpolation

- Add tests for URL interpolation (wss://echo.{{url}}.org)
- Add tests for message content interpolation ({"test": "{{data}}"})
- Update test fixtures to use wss://echo.websocket.org echo server
- Add WEBSOCKET_FLOWS.md documentation
- Refactor queueWsMessage to handle variable interpolation in main process

* removed ws flow documentation

* chore: updated the network/index.js file to reduce merge conflicts by moving around code

* fix: added collection and item to WsQueryUrl Editor to fix available variable highlight

* chore: remove unnecessary whitespace in WebSocket event handlers

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-11-17 12:02:25 +05:30
Bobby Bonestell
d28f2f32e9 feat: add support for prompt variables in the bruno app 2025-11-14 18:57:45 +05:30
143 changed files with 6705 additions and 648 deletions

66
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,66 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
language: 'en-US'
early_access: false
tone_instructions: 'You are an expert code reviewer in TypeScript, JavaScript, NodeJS, and ElectronJS. You work in an enterprise software developer team, providing concise and clear code review advice. You only elaborate or provide detailed explanations when requested.'
knowledge_base:
opt_out: false
code_guidelines:
enabled: true
filePatterns:
- '**/CODING_STANDARDS.md'
reviews:
profile: 'chill'
request_changes_workflow: false
high_level_summary: true
poem: true
review_status: true
collapse_walkthrough: false
auto_review:
enabled: true
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:
- Follow best practices for Playwright code and e2e automation
- Try to reduce usage of `page.waitForTimeout();` in code unless absolutely necessary and the locator cannot be found using existing `expect()` playwright calls
- Avoid using `page.pause()` in code
- Use locator variables for locators
- Avoid using test.only
- Use multiple assertions
- Promote the use of `test.step` as much as possible so the generated reports are easier to read
- Ensure that the `fixtures` like the collections are nested inside the `fixtures` folder
**Fixture Example***: Here's an example of possible fixture and test pair
```
.
├── fixtures
│ └── collection
│ ├── base.bru
│ ├── bruno.json
│ ├── collection.bru
│ ├── ws-test-request-with-headers.bru
│ ├── ws-test-request-with-subproto.bru
│ └── ws-test-request.bru
├── connection.spec.ts # <- Depends on the collection in ./fixtures/collection
├── headers.spec.ts
├── persistence.spec.ts
├── variable-interpolation
│ ├── fixtures
│ │ └── collection
│ │ ├── environments
│ │ ├── bruno.json
│ │ └── ws-interpolation-test.bru
│ ├── init-user-data
│ └── variable-interpolation.spec.ts # <- Depends on the collection in ./variable-interpolation/fixtures/collection
└── subproto.spec.ts
```
chat:
auto_reply: true

44
CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,44 @@
# Bruno Coding Standards
- No diffs unless an actual change is made, the code changes need to be as minimal as possible, avoid making un-necessary whitespace diffs. This is already handled by eslint but make sure you check your code changes before commiting and raising a PR.
## General Style Rules
- Use 2 spaces for indentation. No tabs, just spaces keeps everything neat and uniform.
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence clarity matters.
- JSX is enabled, so feel free to use it where it makes sense.
## Punctuation and Spacing
- No trailing commas. Keep it clean, no extra commas hanging around.
- Always use parentheses around parameters in arrow functions. Even for single params consistency is key.
- For multiline constructs, put opening braces on the same line, and ensure consistency. Minimum 2 elements for multiline.
- No newlines inside function parentheses. Keep 'em tight.
- Space before and after the arrow in arrow functions. `() => {}` is good.
- No space between function name and parentheses. `func()` not `func ()`.
- Semicolons go at the end of the line, not on a new line.
- No strict max length write readable code, not cramped lines.
- Multiple expressions per line in JSX are fine flexibility is nice.
Remember, these rules are here to make our codebase harmonious. If something doesn't fit perfectly, let's chat about it. Happy coding! 🚀
## Readability and Abstractions
- Avoid abstractions unless the exact same code is being used in more than 3 places.
- Names for functions need to be concise and descriptive.
- Add in JSDoc comments to add more details to the abstractions if needed.
- Follow functional programming but just enough to be readable, we don't need to go as deep as ADTs and Monads, we want to keep the code pipeline obvious and easy for everyone to read and contribute to.
- Avoid single line abstractions where all that's being done is increasing the call stack with one additional function.
- Add in meaningful comments instead of obvious ones where complex code flow is explained properly.

48
package-lock.json generated
View File

@@ -3906,7 +3906,7 @@
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
@@ -4469,9 +4469,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11639,7 +11639,7 @@
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"parse-json": "^5.2.0"
},
"engines": {
@@ -17787,9 +17787,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -22268,7 +22268,7 @@
"config-file-ts": "^0.2.4",
"dotenv": "^9.0.2",
"dotenv-expand": "^5.1.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"json5": "^2.2.0",
"lazy-val": "^1.0.4"
},
@@ -26871,6 +26871,7 @@
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "^10.3.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
@@ -28420,6 +28421,15 @@
"node": ">=18.0.0"
}
},
"packages/bruno-app/node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"packages/bruno-app/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -28468,6 +28478,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"packages/bruno-app/node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -28523,7 +28539,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
@@ -30147,7 +30163,7 @@
"license": "MIT",
"dependencies": {
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8",
@@ -30288,7 +30304,7 @@
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
@@ -31528,7 +31544,7 @@
"hosted-git-info": "^4.1.0",
"is-ci": "^3.0.0",
"isbinaryfile": "^5.0.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"lazy-val": "^1.0.5",
"minimatch": "^5.1.1",
"read-config-file": "6.3.2",
@@ -31575,7 +31591,7 @@
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.1",
"is-ci": "^3.0.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"source-map-support": "^0.5.19",
"stat-mode": "^1.0.0",
"temp-file": "^3.4.0"
@@ -31706,7 +31722,7 @@
"builder-util-runtime": "9.2.4",
"fs-extra": "^10.1.0",
"iconv-lite": "^0.6.2",
"js-yaml": "^4.1.0"
"js-yaml": "^4.1.1"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
@@ -32256,7 +32272,7 @@
"express-basic-auth": "^1.2.1",
"fast-xml-parser": "^5.0.8",
"http-proxy": "^1.18.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",

View File

@@ -48,6 +48,7 @@
"jsonc-parser": "^3.2.1",
"jsonpath-plus": "^10.3.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",

View File

@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
@@ -53,9 +54,11 @@ export default class CodeEditor extends React.Component {
lineWrapping: this.props.enableLineWrapping ?? true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
@@ -202,6 +205,8 @@ export default class CodeEditor extends React.Component {
editor,
autoCompleteOptions
);
setupLinkAware(editor);
}
}
@@ -227,6 +232,16 @@ export default class CodeEditor extends React.Component {
if (!isEqual(variables, this.variables)) {
this.addOverlay();
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
@@ -254,6 +269,7 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
this.editor = null;
@@ -290,6 +306,11 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
// Update brunoVarInfo with latest variables
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -45,7 +45,8 @@ const Headers = ({ collection }) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {

View File

@@ -4,6 +4,7 @@ import StyledWrapper from './StyledWrapper';
import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import DeprecationWarning from 'components/DeprecationWarning';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -35,7 +36,8 @@ const PresetsSettings = ({ collection }) => {
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
<DeprecationWarning featureName="Presets" learnMoreUrl="https://github.com/usebruno/bruno/discussions/6234" />
<div className="text-xs mb-4 mt-4 text-muted">
These presets will be used as the default values for new requests in this collection.
</div>
<div className="bruno-form">

View File

@@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -114,7 +114,7 @@ const VarsTable = ({ collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -4,12 +4,14 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import DeprecationWarning from 'components/DeprecationWarning';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
@@ -18,6 +20,7 @@ const Vars = ({ collection }) => {
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<DeprecationWarning featureName="Post Response Variables" learnMoreUrl="https://github.com/usebruno/bruno/discussions/6231" />
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">

View File

@@ -0,0 +1,42 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.deprecation-warning {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
padding: 8px;
gap: 4px;
margin-bottom: 8px;
background: ${(props) => props.theme.deprecationWarning.bg};
border: 1px solid ${(props) => props.theme.deprecationWarning.border};
border-radius: 6px;
.warning-icon {
color: ${(props) => props.theme.deprecationWarning.icon};
flex-shrink: 0;
width: 16px;
height: 16px;
}
.warning-text {
font-family: 'Inter', sans-serif;
font-style: normal;
font-size: 14px;
line-height: 17px;
color: ${(props) => props.theme.deprecationWarning.text};
a {
color: ${(props) => props.theme.textLink};
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import IconAlertTriangleFilled from '../Icons/IconAlertTriangleFilled';
import StyledWrapper from './StyledWrapper';
const DeprecationWarning = ({ featureName, learnMoreUrl }) => {
return (
<StyledWrapper>
<div className="deprecation-warning">
<IconAlertTriangleFilled className="warning-icon" size={16} />
<span className="warning-text">
{featureName} will be removed in <strong>v3.0.0</strong>. They are deprecated and will no longer be supported. Learn more in{' '}
<a href={learnMoreUrl} target="_blank" rel="noreferrer">this post</a> or contact us at{' '}
<a href="mailto:support@usebruno.com">support@usebruno.com</a> with questions.
</span>
</div>
</StyledWrapper>
);
};
export default DeprecationWarning;

View File

@@ -221,6 +221,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{!variable.secret && hasSensitiveUsage(variable.name) && (

View File

@@ -41,7 +41,8 @@ const Headers = ({ collection, folder }) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {

View File

@@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -113,7 +113,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -4,12 +4,14 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import DeprecationWarning from 'components/DeprecationWarning';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
@@ -18,6 +20,7 @@ const Vars = ({ collection, folder }) => {
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<DeprecationWarning featureName="Post Response Variables" learnMoreUrl="https://github.com/usebruno/bruno/discussions/6231" />
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">

View File

@@ -162,6 +162,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{typeof variable.value !== 'string' && (

View File

@@ -0,0 +1,24 @@
import React from 'react';
const CloseAllIcon = ({ size = 18, strokeWidth = 1.5, className = '', ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M7 7L7 5C7 4.46957 7.21072 3.96086 7.58579 3.58579C7.96086 3.21071 8.46957 3 9 3L19 3C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5L21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17L17 17M17 19C17 19.5304 16.7893 20.0391 16.4142 20.4142C16.0391 20.7893 15.5304 21 15 21L5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19L3 9C3 8.46957 3.21072 7.96086 3.58579 7.58579C3.96086 7.21071 4.46957 7 5 7L15 7C15.5304 7 16.0391 7.21071 16.4142 7.58579C16.7893 7.96086 17 8.46957 17 9L17 19Z"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 11L7 17M7 11L13 17"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default CloseAllIcon;

View File

@@ -0,0 +1,14 @@
import React from 'react';
const IconAlertTriangleFilled = ({ size = 16, ...props }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
<path
d="M12 1.67c.955 0 1.845 .467 2.39 1.247l.105 .16l8.114 13.548a2.914 2.914 0 0 1 -2.307 4.363l-.195 .008h-16.225a2.914 2.914 0 0 1 -2.582 -4.2l.099 -.185l8.11 -13.538a2.914 2.914 0 0 1 2.491 -1.403zm.01 13.33l-.127 .007a1 1 0 0 0 0 1.986l.117 .007l.127 -.007a1 1 0 0 0 0 -1.986l-.117 -.007zm-.01 -7a1 1 0 0 0 -.993 .883l-.007 .117v4l.007 .117a1 1 0 0 0 1.986 0l.007 -.117v-4l-.007 -.117a1 1 0 0 0 -.993 -.883z"
fill="currentColor"
/>
</svg>
);
};
export default IconAlertTriangleFilled;

View File

@@ -5,6 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -30,12 +31,16 @@ class MultiLineEditor extends Component {
const variables = getAllVariables(this.props.collection, this.props.item);
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
@@ -82,6 +87,8 @@ class MultiLineEditor extends Component {
this.editor,
autoCompleteOptions
);
setupLinkAware(this.editor);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
@@ -125,9 +132,21 @@ class MultiLineEditor extends Component {
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
this.addOverlay(variables);
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
@@ -154,6 +173,9 @@ class MultiLineEditor extends Component {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;

View File

@@ -79,7 +79,7 @@ const ReorderTable = ({ children, updateReorderedItem }) => {
<>
<div
draggable
className="group drag-handle absolute z-10 left-[-17px] p-3.5 py-3.5 px-2.5 top-[3px] cursor-grab"
className="group drag-handle absolute z-10 left-[-17px] top-1/2 -translate-y-[80%] p-2.5 cursor-grab"
>
{hoveredRow === index && (
<>

View File

@@ -4,7 +4,8 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { IconPlus, IconTrash, IconAdjustmentsHorizontal } from '@tabler/icons';
import { cloneDeep } from "lodash";
import SingleLineEditor from "components/SingleLineEditor/index";
import SingleLineEditor from 'components/SingleLineEditor/index';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from "./StyledWrapper";
import Table from "components/Table/index";
@@ -205,7 +206,7 @@ const AdditionalParams = ({ item = {}, request, updateAuth, collection, handleS
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param?.value || ''}
theme={storedTheme}
onChange={(value) => handleUpdateAdditionalParam({

View File

@@ -41,8 +41,8 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
itemUid: item.uid,
collectionUid: collection.uid
}));
};

View File

@@ -129,11 +129,15 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
setProtoFilePath('');
setIsReflectionMode(true);
dispatch(updateRequestProtoPath({
protoPath: '',
itemUid: item.uid,
collectionUid: collection.uid
}));
// Only update protoPath if it was previously set (to avoid creating unnecessary draft state)
const currentProtoPath = getPropertyFromDraftOrRequest(item, 'request.protoPath', '');
if (currentProtoPath) {
dispatch(updateRequestProtoPath({
protoPath: '',
itemUid: item.uid,
collectionUid: collection.uid
}));
}
if (methods && methods.length > 0) {
toast.success(`Loaded ${methods.length} gRPC methods from reflection`);

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-height: 60vh;
overflow-y: auto;
padding: 0 0.2rem;
`;
export default StyledWrapper;

View File

@@ -0,0 +1,56 @@
import React, { useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
import { IconAlertTriangle } from '@tabler/icons';
export default function PromptVariablesModal({ title = 'Input Required', prompts, onSubmit, onCancel }) {
const [values, setValues] = useState({});
const handleChange = (prompt, value) => {
setValues((prev) => ({ ...prev, [prompt]: value }));
};
if (!prompts?.length) {
return null;
}
return (
<Portal>
<Modal
size="lg"
title={title}
confirmText="Continue"
cancelText="Cancel"
handleConfirm={() => onSubmit(values)}
handleCancel={onCancel}
>
<StyledWrapper data-testid="prompt-variables-modal-content">
<div className="space-y-5 mt-2">
{prompts.map((prompt, index) => (
<div key={prompt} data-testid="prompt-variable-input-container">
<label htmlFor={`prompt-${index}`} className="block font-semibold">
{prompt}
</label>
<input
id={`prompt-${index}`}
type="text"
data-testid={`prompt-variable-input-${index}`}
className="textbox mt-2 w-full"
placeholder="Enter value"
value={values[prompt] || ''}
onChange={(e) => handleChange(prompt, e.target.value)}
autoFocus={index === 0}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
))}
</div>
</StyledWrapper>
</Modal>
</Portal>
);
}

View File

@@ -17,6 +17,7 @@ import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
import { setupLinkAware } from 'utils/codemirror/linkAware';
const CodeMirror = require('codemirror');
@@ -138,6 +139,8 @@ export default class QueryEditor extends React.Component {
editor.on('beforeChange', this._onBeforeChange);
}
this.addOverlay();
setupLinkAware(editor);
}
componentDidUpdate(prevProps) {
@@ -170,6 +173,9 @@ export default class QueryEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('keyup', this._onKeyUp);
this.editor.off('hasCompletion', this._onHasCompletion);

View File

@@ -13,7 +13,7 @@ import {
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -168,13 +168,14 @@ const QueryParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={handleRun}
collection={collection}
item={item}
variablesAutocomplete={true}
/>
</td>
@@ -244,7 +245,7 @@ const QueryParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={path.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -117,6 +117,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div

View File

@@ -20,7 +20,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
@@ -36,9 +36,11 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {
case 'name': {
header.name = e.target.value;
// Strip newlines from header keys
header.name = e.target.value.replace(/[\r\n]/g, '');
break;
}
case 'value': {
@@ -50,6 +52,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
break;
}
}
dispatch(
updateRequestHeader({
header: header,
@@ -154,7 +157,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
}
onRun={handleRun}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
/>

View File

@@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -122,7 +122,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={_var.value}
theme={storedTheme}
onSave={onSave}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import DeprecationWarning from 'components/DeprecationWarning';
const Vars = ({ item, collection }) => {
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
@@ -15,6 +16,7 @@ const Vars = ({ item, collection }) => {
</div>
<div>
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<DeprecationWarning featureName="Post Response Variables" learnMoreUrl="https://github.com/usebruno/bruno/discussions/6231" />
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
</div>
</StyledWrapper>

View File

@@ -104,6 +104,8 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
className="w-full"
theme={displayedTheme}
onRun={handleRun}
collection={collection}
item={item}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div

View File

@@ -9,7 +9,6 @@ import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
@@ -33,6 +32,7 @@ import ExampleNotFound from './ExampleNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
const MIN_LEFT_PANE_WIDTH = 300;
@@ -70,15 +70,9 @@ const RequestTabPanel = () => {
});
let collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 is intentional to make both panes appear to be of equal width
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
@@ -97,22 +91,6 @@ const RequestTabPanel = () => {
}
};
useEffect(() => {
// Initialize vertical heights when switching to vertical layout
if (mainSectionRef.current) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const initialHeight = mainRect.height / 2;
setTopPaneHeight(initialHeight);
// In vertical mode, set leftPaneWidth to full container width
setLeftPaneWidth(mainRect.width);
} else {
// In horizontal mode, set to roughly half width
setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
}
}
}, [isVerticalLayout, screenWidth, asideWidth]);
const handleMouseMove = (e) => {
if (dragging && mainSectionRef.current) {
e.preventDefault();
@@ -130,40 +108,22 @@ const RequestTabPanel = () => {
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
}
};
const handleMouseUp = (e) => {
if (dragging && mainSectionRef.current) {
if (dragging) {
e.preventDefault();
setDragging(false);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
})
);
}
}
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
setDragging(true);
if (isVerticalLayout) {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.y = e.clientY - dragBarRect.top;
} else {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.x = e.clientX - dragBarRect.left;
}
};
useEffect(() => {
@@ -329,7 +289,14 @@ const RequestTabPanel = () => {
</div>
</section>
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
>
<div className="dragbar-handle" />
</div>

View File

@@ -6,13 +6,13 @@ import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder
// including the folder itself and its children
@@ -30,6 +30,9 @@ const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
})
);
}
}).catch((error) => {
console.error('Error deleting item', error);
toast.error(error?.message || 'Error deleting item');
});
onClose();
};

View File

@@ -12,7 +12,7 @@ import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getAllVariables, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { resolveInheritedAuth } from './utils/auth-utils';
import { resolveInheritedAuth } from 'utils/auth';
const TEMPLATE_VAR_PATTERN = /\{\{([^}]+)\}\}/;

View File

@@ -1,47 +1,9 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody } from './interpolation';
import { get } from 'lodash';
// Merge headers from collection, folders, and request
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
// Add collection headers first
const collectionHeaders = collection?.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
// Add folder headers next, traversing from root to leaf
if (requestTreePath && requestTreePath.length > 0) {
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderHeaders = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'root.request.headers', []);
folderHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
}
}
}
// Add request headers last (they take precedence)
const requestHeaders = request.headers || [];
requestHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
// Convert Map back to array
return Array.from(headers.values());
};
const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
try {
// Get HTTPSnippet dynamically so mocks can be applied in tests
@@ -88,6 +50,5 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
};
export {
generateSnippet,
mergeHeaders
generateSnippet
};

View File

@@ -45,19 +45,24 @@ jest.mock('utils/codegenerator/auth', () => ({
getAuthHeaders: jest.fn(() => [])
}));
jest.mock('utils/collections/index', () => ({
getAllVariables: jest.fn((collection) => ({
...collection?.globalEnvironmentVariables,
...collection?.runtimeVariables,
...collection?.processEnvVariables,
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'
})),
getTreePathFromCollectionToItem: jest.fn(() => [])
}));
jest.mock('utils/collections/index', () => {
const actual = jest.requireActual('utils/collections/index');
import { generateSnippet, mergeHeaders } from './snippet-generator';
return {
...actual,
getAllVariables: jest.fn((collection) => ({
...collection?.globalEnvironmentVariables,
...collection?.runtimeVariables,
...collection?.processEnvVariables,
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'
})),
getTreePathFromCollectionToItem: jest.fn(() => [])
};
});
import { generateSnippet } from './snippet-generator';
describe('Snippet Generator - Simple Tests', () => {
@@ -424,41 +429,6 @@ describe('Snippet Generator - Simple Tests', () => {
});
});
describe('mergeHeaders', () => {
it('should include headers from collection, folder and request (with correct precedence)', () => {
const collection = {
root: {
request: {
headers: [
{ name: 'X-Collection', value: 'c', enabled: true }
]
}
}
};
const folder = {
type: 'folder',
root: {
request: {
headers: [
{ name: 'X-Folder', value: 'f', enabled: true }
]
}
}
};
const request = {
headers: [
{ name: 'X-Request', value: 'r', enabled: true }
]
};
const headers = mergeHeaders(collection, request, [folder]);
const names = headers.map((h) => h.name);
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
});
});
// Snippet should include inherited headers
describe('generateSnippet header inclusion in output', () => {
it('should include collection and folder headers in generated snippet', () => {

View File

@@ -0,0 +1,122 @@
import React from 'react';
import filter from 'lodash/filter';
import { useDispatch } from 'react-redux';
import { flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => {
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const dispatch = useDispatch();
// Get all draft items in the collection
const currentDrafts = React.useMemo(() => {
if (!collection) return [];
const items = flattenItems(collection.items);
const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
return collectionDrafts.map((draft) => ({
...draft,
collectionUid: collectionUid
}));
}, [collection, collectionUid]);
const handleSaveAll = () => {
dispatch(saveMultipleRequests(currentDrafts))
.then(() => {
dispatch(removeCollection(collectionUid))
.then(() => {
toast.success('Collection closed');
onClose();
})
.catch(() => toast.error('An error occurred while closing the collection'));
})
.catch(() => {
toast.error('Failed to save requests!');
});
};
const handleDiscardAll = () => {
// Discard all drafts
currentDrafts.forEach((draft) => {
dispatch(deleteRequestDraft({
collectionUid: collectionUid,
itemUid: draft.uid
}));
});
// Then close the collection
dispatch(removeCollection(collectionUid))
.then(() => {
toast.success('Collection closed');
onClose();
})
.catch(() => toast.error('An error occurred while closing the collection'));
};
if (!currentDrafts.length) {
return null;
}
return (
<Modal
size="md"
title="Close Collection"
confirmText="Save and Close"
cancelText="Close without saving"
handleCancel={onClose}
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
hideFooter={true}
>
<div className="flex items-center">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<p className="mt-4">
Do you want to save the changes you made to the following{' '}
<span className="font-medium">{currentDrafts.length}</span> {pluralizeWord('request', currentDrafts.length)}?
</p>
<ul className="mt-4">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs">
{item.filename}
</li>
);
})}
</ul>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="mt-1 text-xs">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
)}
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={handleDiscardAll}>
Discard and Close
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onClose}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleSaveAll}>
{currentDrafts.length > 1 ? 'Save All and Close' : 'Save and Close'}
</button>
</div>
</div>
</Modal>
);
};
export default ConfirmCollectionCloseDrafts;

View File

@@ -1,15 +1,24 @@
import React from 'react';
import React, { useMemo } from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections/index';
import filter from 'lodash/filter';
import ConfirmCollectionCloseDrafts from './ConfirmCollectionCloseDrafts';
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
// Detect drafts in the collection
const drafts = useMemo(() => {
if (!collection) return [];
const items = flattenItems(collection.items);
return filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
}, [collection]);
const onConfirm = () => {
dispatch(removeCollection(collection.uid))
.then(() => {
@@ -19,6 +28,12 @@ const RemoveCollection = ({ onClose, collectionUid }) => {
.catch(() => toast.error('An error occurred while closing the collection'));
};
// If there are drafts, show the draft confirmation modal
if (drafts.length > 0) {
return <ConfirmCollectionCloseDrafts onClose={onClose} collection={collection} collectionUid={collectionUid} />;
}
// Otherwise, show the standard close confirmation modal
return (
<Modal size="sm" title="Close Collection" confirmText="Close" handleConfirm={onConfirm} handleCancel={onClose}>
<div className="flex items-center">

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.collections-badge {
margin-inline: 0.5rem;
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 5px;
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.collections-header-actions {
.collection-action-button {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconArrowsSort, IconFolders, IconSortAscendingLetters, IconSortDescendingLetters } from '@tabler/icons';
import CloseAllIcon from 'components/Icons/CloseAll';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import RemoveCollectionsModal from '../RemoveCollectionsModal';
import StyledWrapper from './StyledWrapper';
const CollectionsHeader = () => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
let sortIcon;
if (collectionSortOrder === 'default') {
sortIcon = <IconArrowsSort size={18} strokeWidth={1.5} />;
} else if (collectionSortOrder === 'alphabetical') {
sortIcon = <IconSortAscendingLetters size={18} strokeWidth={1.5} />;
} else {
sortIcon = <IconSortDescendingLetters size={18} strokeWidth={1.5} />;
}
const selectAllCollectionsToClose = () => {
setCollectionsToClose(collections.map((c) => c.uid));
};
const clearCollectionsToClose = () => {
setCollectionsToClose([]);
};
return (
<StyledWrapper>
<div className="collections-badge flex items-center justify-between px-2 mt-2 relative">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
</div>
{collections.length >= 1 && (
<div className="flex items-center collections-header-actions">
<button
className="mr-1 collection-action-button"
onClick={selectAllCollectionsToClose}
aria-label="Close all collections"
title="Close all collections"
data-testid="close-all-collections-button"
>
<CloseAllIcon size={18} strokeWidth={1.5} className="cursor-pointer" />
</button>
<button
className="collection-action-button"
onClick={() => sortCollectionOrder()}
aria-label="Sort collections"
title="Sort collections"
>
{sortIcon}
</button>
{collectionsToClose.length > 0 && (
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
)}
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionsHeader;

View File

@@ -0,0 +1,60 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
width: 600px;
overflow: hidden;
box-sizing: border-box;
.collections-list-container {
width: 100%;
max-height: 150px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
box-sizing: border-box;
}
.collections-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.collection-tag {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background-color: ${(props) => props.theme.requestTabs.active.bg};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collection-tag-text {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.show-more-link,
.show-less-link {
display: inline-flex;
align-items: center;
&:hover {
span {
text-decoration: underline;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,273 @@
import React, { useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { filter, groupBy } from 'lodash';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import {
removeCollection,
saveMultipleRequests,
saveMultipleCollections,
saveMultipleFolders
} from 'providers/ReduxStore/slices/collections/actions';
import {
findCollectionByUid,
flattenItems,
isItemARequest,
isItemAFolder,
hasRequestChanges
} from 'utils/collections/index';
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const MAX_COLLECTIONS_WIDTH = 530;
const CHARACTER_WIDTH = 8;
const COLLECTION_PADDING = 24;
const COLLECTION_GAP = 12;
const getDisplayItems = (items, maxWidth = MAX_COLLECTIONS_WIDTH) => {
const visibleItems = [];
let totalWidth = 0;
for (let i = 0; i < items.length; i += 1) {
const currentItem = items[i];
const name = typeof currentItem === 'string' ? currentItem : currentItem?.name || '';
const width = name.length * CHARACTER_WIDTH + COLLECTION_PADDING + COLLECTION_GAP;
if (i === 0 || totalWidth + width <= maxWidth) {
totalWidth += width;
visibleItems.push(currentItem);
} else {
break;
}
}
return visibleItems;
};
const RemoveCollectionsModal = ({ collectionUids, onClose }) => {
const dispatch = useDispatch();
const allCollections = useSelector((state) => state.collections.collections || []);
const [showAllCollections, setShowAllCollections] = useState(false);
const allDrafts = useMemo(() => {
const requestDrafts = [];
const collectionDrafts = [];
const folderDrafts = [];
collectionUids.forEach((collectionUid) => {
const collection = findCollectionByUid(allCollections, collectionUid);
if (!collection) {
return;
}
// Check for collection draft
if (collection.draft) {
collectionDrafts.push({
name: collection.name,
collectionUid: collectionUid
});
}
// Check for request and folder drafts
const items = flattenItems(collection.items);
// Request drafts
const unsavedRequests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
unsavedRequests.forEach((request) => {
requestDrafts.push({
...request,
collectionUid: collectionUid
});
});
// Folder drafts
const unsavedFolders = filter(items, (item) => isItemAFolder(item) && item.draft);
unsavedFolders.forEach((folder) => {
folderDrafts.push({
name: folder.name,
folderUid: folder.uid,
collectionUid: collectionUid
});
});
});
return { requestDrafts, collectionDrafts, folderDrafts };
}, [collectionUids, allCollections]);
const collectionsWithUnsavedChanges = useMemo(() => {
const allDraftTypes = [...allDrafts.collectionDrafts, ...allDrafts.folderDrafts, ...allDrafts.requestDrafts];
const draftsByCollection = groupBy(allDraftTypes, 'collectionUid');
return Object.keys(draftsByCollection)
.map((collectionUid) => {
const collection = findCollectionByUid(allCollections, collectionUid);
return collection ? { uid: collectionUid, name: collection.name } : null;
})
.filter(Boolean);
}, [allDrafts, allCollections]);
const hasUnsavedChanges
= allDrafts.collectionDrafts.length > 0 || allDrafts.folderDrafts.length > 0 || allDrafts.requestDrafts.length > 0;
const handleCloseAllCollections = () => {
const removalPromises = collectionUids.map((uid) => dispatch(removeCollection(uid)));
Promise.all(removalPromises)
.then(() => {
toast.success('Closed all collections');
})
.catch((error) => {
console.error('Error closing collections:', error);
toast.error('An error occurred while closing collections');
})
.finally(() => {
onClose();
});
};
const handleDiscard = () => {
handleCloseAllCollections();
};
const handleCancel = () => {
onClose();
};
const handleSave = async () => {
try {
const savePromises = [];
// Save all collection drafts
if (allDrafts.collectionDrafts.length > 0) {
savePromises.push(dispatch(saveMultipleCollections(allDrafts.collectionDrafts)));
}
// Save all folder drafts
if (allDrafts.folderDrafts.length > 0) {
savePromises.push(dispatch(saveMultipleFolders(allDrafts.folderDrafts)));
}
// Save all request drafts
if (allDrafts.requestDrafts.length > 0) {
savePromises.push(dispatch(saveMultipleRequests(allDrafts.requestDrafts)));
}
await Promise.all(savePromises);
handleCloseAllCollections();
} catch (error) {
console.error('Error saving drafts:', error);
toast.error('An error occurred while saving changes');
handleCancel();
}
};
if (collectionUids.length === 0) {
return null;
}
const hasMultipleCollections = collectionUids.length > 1;
const singleCollectionName = hasMultipleCollections
? null
: findCollectionByUid(allCollections, collectionUids[0])?.name;
const displayedCollections = useMemo(() => showAllCollections ? collectionsWithUnsavedChanges : getDisplayItems(collectionsWithUnsavedChanges),
[collectionsWithUnsavedChanges, showAllCollections]);
const hasMoreCollections = collectionsWithUnsavedChanges.length > displayedCollections.length;
const hiddenCollectionsCount = collectionsWithUnsavedChanges.length - displayedCollections.length;
const toggleButton = hasMoreCollections || showAllCollections ? (
<span
className={`${showAllCollections ? 'show-less-link' : 'show-more-link'} w-fit flex items-center mt-2 cursor-pointer`}
onClick={() => setShowAllCollections(!showAllCollections)}
>
<span className="text-sm text-link">
{showAllCollections ? 'Show less' : `Show ${hiddenCollectionsCount} more`}
</span>
</span>
) : null;
return (
<Portal>
<Modal
size="md"
title="Close all collections"
disableEscapeKey={hasUnsavedChanges}
disableCloseOnOutsideClick={hasUnsavedChanges}
handleCancel={handleCancel}
hideFooter={true}
>
<StyledWrapper>
{hasUnsavedChanges ? (
<>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">
Do you want to save changes you made to the following{' '}
{collectionsWithUnsavedChanges.length === 1 ? 'collection' : 'collections'}?
</div>
<div className="mt-2 text-xs text-gray-500">
Collections will still be available in the file system and can be re-opened later.
</div>
<div className="mt-4">
<div className="collections-list-container">
<div className="collections-list">
{displayedCollections.map(({ uid, name }) => (
<span key={uid} className="collection-tag">
<span className="collection-tag-text">{name}</span>
</span>
))}
{toggleButton}
</div>
</div>
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={handleDiscard}>
Discard and Close
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={handleCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleSave}>
Save and Close
</button>
</div>
</div>
</>
) : (
<>
<div className="mt-4">
{hasMultipleCollections ? (
`Are you sure you want to close all ${collectionUids.length} collections in Bruno?`
) : (
<>
Are you sure you want to close the collection <strong>{singleCollectionName}</strong> in Bruno?
</>
)}
</div>
<div className="mt-4 text-xs text-gray-500">
Collections will still be available in the file system and can be re-opened later.
</div>
<div className="flex justify-end mt-6">
<button className="btn btn-close btn-sm mr-2" onClick={handleCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleCloseAllCollections}>
{hasMultipleCollections ? 'Close All' : 'Close'}
</button>
</div>
</>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default RemoveCollectionsModal;

View File

@@ -1,21 +1,13 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.collections-badge {
margin-inline: 0.5rem;
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 5px;
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
span.close-icon {
color: ${(props) => props.theme.colors.text.muted};
}
&:hover .collections-badge .collections-header-actions .collection-action-button {
opacity: 1;
}
`;
export default Wrapper;

View File

@@ -1,64 +1,14 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconSearch,
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
IconSortDescendingLetters,
IconX
} from '@tabler/icons';
import Collection from './Collection';
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import Collection from './Collection';
import CollectionsHeader from './CollectionsHeader';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
// the coding convention is to keep all the components in a folder named after the component
const CollectionsBadge = () => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
return (
<div className="items-center mt-2 relative">
<div className="collections-badge flex items-center justify-between px-2">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
</div>
{collections.length >= 1 && (
<button onClick={() => sortCollectionOrder()}>
{collectionSortOrder == 'default' ? (
<IconArrowsSort size={18} strokeWidth={1.5} />
) : collectionSortOrder == 'alphabetical' ? (
<IconSortAscendingLetters size={18} strokeWidth={1.5} />
) : (
<IconSortDescendingLetters size={18} strokeWidth={1.5} />
)}
</button>
)}
</div>
</div>
);
};
import StyledWrapper from './StyledWrapper';
const Collections = () => {
const [searchText, setSearchText] = useState('');
@@ -67,18 +17,18 @@ const Collections = () => {
if (!collections || !collections.length) {
return (
<StyledWrapper>
<CollectionsBadge />
<StyledWrapper data-testid="collections">
<CollectionsHeader />
<CreateOrOpenCollection />
</StyledWrapper>
);
}
return (
<StyledWrapper>
<StyledWrapper data-testid="collections">
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
<CollectionsBadge />
<CollectionsHeader />
<div className="mt-4 relative collection-filter px-2">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">

View File

@@ -6,6 +6,7 @@ import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { setupLinkAware } from 'utils/codemirror/linkAware';
const CodeMirror = require('codemirror');
@@ -40,7 +41,7 @@ class SingleLineEditor extends Component {
this.props.onSave();
}
};
const noopHandler = () => {};
const noopHandler = () => { };
this.editor = CodeMirror(this.editorRef.current, {
placeholder: this.props.placeholder ?? '',
@@ -48,9 +49,11 @@ class SingleLineEditor extends Component {
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: {
variables
},
brunoVarInfo: this.props.enableBrunoVarInfo !== false ? {
variables,
collection: this.props.collection,
item: this.props.item
} : false,
scrollbarStyle: null,
tabindex: 0,
readOnly: this.props.readOnly,
@@ -92,13 +95,19 @@ class SingleLineEditor extends Component {
this.editor,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.editor.on('paste', this._onPaste);
this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
this.setState({ maskInput: this.props.isSecret });
// Add newline arrow markers if enabled
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
setupLinkAware(this.editor);
}
/** Enable or disable masking the rendered content of the editor */
@@ -123,6 +132,11 @@ class SingleLineEditor extends Component {
if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
// Update newline markers after edit
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
};
@@ -136,15 +150,32 @@ class SingleLineEditor extends Component {
let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
this.editor.options.brunoVarInfo.variables = variables;
}
this.addOverlay(variables);
}
// Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
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 ?? ''));
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
@@ -160,8 +191,12 @@ class SingleLineEditor extends Component {
componentWillUnmount() {
if (this.editor) {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.editor.off('change', this._onEdit);
this.editor.off('paste', this._onPaste);
this._clearNewlineMarkers();
this.editor.getWrapperElement().remove();
this.editor = null;
}
@@ -180,6 +215,63 @@ class SingleLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables');
};
/**
* Update markers to show arrows for newlines
*/
_updateNewlineMarkers = () => {
if (!this.editor) return;
// Clear existing markers
this._clearNewlineMarkers();
this.newlineMarkers = [];
const content = this.editor.getValue();
// Find all newlines and replace them with arrow widgets
for (let i = 0; i < content.length; i++) {
if (content[i] === '\n') {
const pos = this.editor.posFromIndex(i);
const nextPos = this.editor.posFromIndex(i + 1);
// Create a widget to display the arrow
const arrow = document.createElement('span');
arrow.className = 'newline-arrow';
arrow.textContent = '↲';
arrow.style.cssText = `
color: #888;
font-size: 8px;
margin: 0 2px;
vertical-align: middle;
display: inline-block;
`;
// Mark the newline character and replace it with the arrow widget
const marker = this.editor.markText(pos, nextPos, {
replacedWith: arrow,
handleMouseEvents: true
});
this.newlineMarkers.push(marker);
}
}
};
/**
* Clear all newline markers
*/
_clearNewlineMarkers = () => {
if (this.newlineMarkers) {
this.newlineMarkers.forEach((marker) => {
try {
marker.clear();
} catch (e) {
// Marker might already be cleared
}
});
this.newlineMarkers = [];
}
};
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
@@ -204,13 +296,15 @@ class SingleLineEditor extends Component {
render() {
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<div className={`flex flex-row items-center w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper
ref={this.editorRef}
className={`single-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`}
{...(this.props['data-testid'] ? { 'data-testid': this.props['data-testid'] } : {})}
/>
{this.secretEye(this.props.isSecret)}
<div className="flex items-center">
{this.secretEye(this.props.isSecret)}
</div>
</div>
);
}

View File

@@ -32,7 +32,7 @@ export const TabsTrigger = ({ value: triggerValue, children, className = '' }) =
return (
<button
onClick={() => onValueChange(triggerValue)}
className={`inline-flex items-center justify-center rounded-[4px] p-[8px] text-sm whitespace-nowrap transition-all cursor-pointer border border-transparent hover:opacity-90 ${className}`}
className={`inline-flex items-center justify-center rounded-[4px] p-[8px] text-xs whitespace-nowrap transition-all cursor-pointer border border-transparent hover:opacity-90 ${className}`}
style={{
background: isActive ? theme.tabs.secondary.active.bg : 'transparent',
color: isActive ? theme.tabs.secondary.active.color : theme.tabs.secondary.inactive.color

View File

@@ -237,23 +237,32 @@ const GlobalStyle = createGlobalStyle`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.cm-variable-prompt {
color: ${(props) => props.theme.codemirror.variable.prompt};
}
}
.CodeMirror-brunoVarInfo {
color: ${(props) => props.theme.codemirror.variable.info.color};
background: ${(props) => props.theme.codemirror.variable.info.bg};
border-radius: 2px;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.border};
border-radius: 0.375rem;
box-shadow: ${(props) => props.theme.codemirror.variable.info.boxShadow};
box-sizing: border-box;
font-size: 13px;
line-height: 16px;
margin: 8px -8px;
max-width: 800px;
font-size: 0.875rem;
line-height: 1.25rem;
margin: 0;
min-width: 18.1875rem;
max-width: 18.1875rem;
opacity: 0;
overflow: hidden;
padding: 8px 8px;
overflow: visible;
padding: 0.5rem;
position: fixed;
transition: opacity 0.15s;
z-index: 50;
z-index: 10;
}
.CodeMirror-hints {
z-index: 50 !important;
}
.CodeMirror-brunoVarInfo :first-child {
@@ -268,10 +277,213 @@ const GlobalStyle = createGlobalStyle`
margin: 1em 0;
}
/* Header */
.CodeMirror-brunoVarInfo .var-info-header {
display: flex;
align-items: center;
margin-bottom: 0.375rem;
gap: 0.375rem;
}
.CodeMirror-brunoVarInfo .var-name {
font-size: 0.875rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
font-weight: 600;
}
/* Scope Badge */
.CodeMirror-brunoVarInfo .var-scope-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
background: #D977061A;
border-radius: 0.25rem;
font-size: 0.75rem;
color: #D97706;
letter-spacing: 0.03125rem;
}
/* Value Container */
.CodeMirror-brunoVarInfo .var-value-container {
position: relative;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.editorBorder};
border-radius: 0.375rem;
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
overflow-y: auto;
overflow-x: hidden;
min-width: 17.3125rem;
max-height: 13.1875rem;
}
/* Value Display (Read-only) */
.CodeMirror-brunoVarInfo .var-value-display {
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-word;
line-height: 1.25rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
min-height: 1.75rem;
max-width: 13.1875rem;
}
/* Value Editor (CodeMirror) */
.CodeMirror-brunoVarInfo .var-value-editor {
width: 100%;
min-width: 17.1875rem;
max-width: 17.1875rem;
max-height: 11.125rem;
position: relative;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror {
height: 100%;
min-height: 1.75rem;
max-height: 11.125rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
border: 0.0625rem solid ${(props) => props.theme.codemirror.variable.info.editorBorder};
border-radius: 0.375rem;
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
color: ${(props) => props.theme.codemirror.variable.info.color};
transition: border-color 0.15s;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-scroll {
min-height: 1.75rem;
max-height: 11.125rem;
overflow-y: auto !important;
overflow-x: hidden !important;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-focused {
background: ${(props) => props.theme.codemirror.variable.info.editorBg};
border-color: ${(props) => props.theme.codemirror.variable.info.editorFocusBorder};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-lines {
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
max-width: 13.1875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror pre {
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
line-height: 1.25rem;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
color: ${(props) => props.theme.codemirror.variable.info.color};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-line {
padding: 0;
max-width: 13.1875rem;
line-height: 1.25rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
color: ${(props) => props.theme.codemirror.variable.info.color};
}
.CodeMirror-brunoVarInfo .var-value-editor .CodeMirror-sizer {
margin-left: 0 !important;
margin-bottom: 0 !important;
max-width: 13.1875rem !important;
}
/* Editable value display (shows interpolated value, click to edit) */
.CodeMirror-brunoVarInfo .var-value-editable-display {
width: 17.1875rem;
max-width: 13.1875rem;
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
font-size: 0.875rem;
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-all;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
line-height: 1.25rem;
color: ${(props) => props.theme.codemirror.variable.info.color};
min-height: 1.75rem;
cursor: text;
border-radius: 0.375rem;
}
/* Icons Container */
.CodeMirror-brunoVarInfo .var-icons {
position: absolute;
top: 0.375rem;
right: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.CodeMirror-brunoVarInfo .secret-toggle-button,
.CodeMirror-brunoVarInfo .copy-button {
background: transparent;
border: none;
cursor: pointer;
padding: 0.125rem;
opacity: 1;
transition: opacity 0.2s;
color: ${(props) => props.theme.codemirror.variable.info.iconColor};
display: flex;
align-items: center;
justify-content: center;
}
.CodeMirror-brunoVarInfo .secret-toggle-button:hover,
.CodeMirror-brunoVarInfo .copy-button:hover {
opacity: 0.7;
}
.CodeMirror-brunoVarInfo .copy-success {
color: #22c55e !important;
}
/* Read-only Note */
.CodeMirror-brunoVarInfo .var-readonly-note {
font-size: 0.625rem;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
margin-top: 0.25rem;
}
.CodeMirror-brunoVarInfo .var-warning-note {
font-size: 0.75rem;
color: #ef4444;
margin-top: 0.375rem;
line-height: 1.25rem;
}
.CodeMirror-hint-active {
background: #08f !important;
color: #fff !important;
}
.hovered-link.CodeMirror-link {
text-decoration: underline !important;
}
.cmd-ctrl-pressed .hovered-link.CodeMirror-link[data-url] {
cursor: pointer;
color: ${(props) => props.theme.textLink} !important;
}
`;
export default GlobalStyle;

View File

@@ -0,0 +1,44 @@
import find from 'lodash/find';
import { updateRequestPaneTabHeight, updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { useDispatch, useSelector } from 'react-redux';
const MIN_TOP_PANE_HEIGHT = 150;
export function useTabPaneBoundaries(activeTabUid) {
const DEFAULT_PANE_WIDTH_DIVISOR = 2.2;
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
const top = focusedTab?.requestPaneHeight;
const dispatch = useDispatch();
return {
left,
top,
setLeft(value) {
dispatch(updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: value
}));
},
setTop(value) {
dispatch(updateRequestPaneTabHeight({
uid: activeTabUid,
requestPaneHeight: value
}));
},
reset() {
dispatch(updateRequestPaneTabHeight({
uid: activeTabUid,
requestPaneHeight: MIN_TOP_PANE_HEIGHT
}));
dispatch(updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR
}));
}
};
}

View File

@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import { AppProvider } from 'providers/App';
import { ToastProvider } from 'providers/Toaster';
import { HotkeysProvider } from 'providers/Hotkeys';
import { PromptVariablesProvider } from 'providers/PromptVariables';
import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
@@ -44,11 +45,13 @@ function Main({ children }) {
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<HotkeysProvider>
{children}
</HotkeysProvider>
</AppProvider>
<PromptVariablesProvider>
<AppProvider>
<HotkeysProvider>
{children}
</HotkeysProvider>
</AppProvider>
</PromptVariablesProvider>
</ToastProvider>
</ThemeProvider>
</Provider>
@@ -57,5 +60,3 @@ function Main({ children }) {
}
export default Main;

View File

@@ -0,0 +1,52 @@
import PromptVariablesModal from 'components/RequestPane/PromptVariables/PromptVariablesModal';
import React, { createContext, useCallback, useState } from 'react';
const PromptVariablesContext = createContext();
export function PromptVariablesProvider({ children }) {
const [modalState, setModalState] = useState({ open: false, prompts: [], resolve: null, reject: null });
const prompt = useCallback((prompts) => {
return new Promise((resolve, reject) => {
setModalState({ open: true, prompts, resolve, reject });
});
}, []);
// Expose globally for non-component code (e.g., Redux thunks)
if (typeof window !== 'undefined') {
window.promptForVariables = async (prompts) => {
try {
return await prompt(prompts);
} catch (err) {
if (err !== 'cancelled') console.error('window.promptForVariables encountered an error:', err);
throw err;
}
};
}
const handleSubmit = (values) => {
modalState.resolve(values);
setModalState({ open: false, prompts: [], resolve: null, reject: null });
};
const handleCancel = () => {
modalState.reject('cancelled');
setModalState({ open: false, prompts: [], resolve: null, reject: null });
};
return (
<PromptVariablesContext.Provider value={{ prompt }}>
{children}
{modalState.open && (
<PromptVariablesModal
title="Input Required"
prompts={modalState.prompts}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
)}
</PromptVariablesContext.Provider>
);
}
export default PromptVariablesProvider;

View File

@@ -1,5 +1,5 @@
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import { parseQueryParams } from '@usebruno/common/utils';
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
@@ -17,6 +17,7 @@ import {
isItemAFolder,
refreshUidsInItem,
isItemARequest,
getAllVariables,
transformRequestToSaveToFilesystem,
transformCollectionRootToSave
} from 'utils/collections';
@@ -46,13 +47,19 @@ import {
saveRequest as _saveRequest,
saveEnvironment as _saveEnvironment,
saveCollectionDraft,
saveFolderDraft
saveFolderDraft,
addVar,
updateVar,
addFolderVar,
updateFolderVar,
addCollectionVar,
updateCollectionVar
} from './index';
import { each } from 'lodash';
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, splitOnFirst } from 'utils/url/index';
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import {
getGlobalEnvironmentVariables,
@@ -62,13 +69,17 @@ import {
resetSequencesInFolder,
getReorderedItemsInSourceDirectory,
calculateDraggedItemNewPathname,
transformFolderRootToSave
transformFolderRootToSave,
getTreePathFromCollectionToItem,
mergeHeaders
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { buildPersistedEnvVariables } from 'utils/environments';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
import { resolveInheritedAuth } from 'utils/auth';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab } from './index';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -379,6 +390,76 @@ export const wsConnectOnly = (item, collectionUid) => (dispatch, getState) => {
});
};
/**
* Extract prompt variables from a request, collection, and environment variables.
* Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible
*
* @param {*} item
* @param {*} collection
* @returns {Promise<Object>} A promise that resolves with the prompt variables or null if no prompt variables are found
*/
const extractPromptVariablesForRequest = async (item, collection) => {
return new Promise(async (resolve, reject) => {
// Ensure window contains promptForVariables function
if (typeof window === 'undefined' || typeof window.promptForVariables !== 'function') {
console.error('Failed to initialize prompt variables: window.promptForVariables is not available. '
+ 'This may indicate an initialization issue with the app environment.');
return resolve(null);
}
const prompts = [];
const request = item.draft?.request ?? item.request ?? {};
const allVariables = getAllVariables(collection, item);
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Get active headers from collection, folders, and request by priority order
const headers = mergeHeaders(collection, request, requestTreePath);
// Get request auth or inherited auth
const resolvedAuthRequest = resolveInheritedAuth(item, collection);
for (let clientCert of clientCertConfig) {
const domain = interpolateUrl({ url: clientCert?.domain, variables: allVariables });
if (domain) {
const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
const requestUrl = interpolateUrl({ url: request.url, variables: allVariables });
if (requestUrl.match(hostRegex)) {
prompts.push(...extractPromptVariables(clientCert));
}
}
}
// Attempt to extract unique prompt variables from anywhere in the request and environment variables.
prompts.push(...extractPromptVariables(allVariables));
prompts.push(...extractPromptVariables(request.body?.[request.body.mode]));
prompts.push(...extractPromptVariables(headers));
prompts.push(...extractPromptVariables(request.params));
prompts.push(...extractPromptVariables(resolvedAuthRequest.auth));
// Remove duplicates
const uniquePrompts = Array.from(new Set(prompts));
// If no prompt variables are found, return null
if (!uniquePrompts?.length) {
return resolve(null);
}
try {
// Prompt user for values if any prompt variables are found
const userValues = await window.promptForVariables(uniquePrompts);
const promptVariables = {};
// Populate runtimeVariables with user input for prompt variables
for (const prompt of uniquePrompts) {
promptVariables[`?${prompt}`] = userValues[prompt] ?? '';
}
return resolve(promptVariables);
} catch (error) {
return reject(error);
}
});
};
export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
@@ -394,9 +475,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const itemCopy = cloneDeep(item);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const requestUid = uuid();
itemCopy.requestUid = requestUid;
try {
const promptVariables = await extractPromptVariablesForRequest(itemCopy, collectionCopy);
collectionCopy.promptVariables = promptVariables ?? {};
} catch (error) {
if (error === 'cancelled') {
return resolve(); // Resolve without error if user cancels prompt
}
return reject(error);
}
await dispatch(
updateResponsePaneScrollPosition({
uid: state.tabs.activeTabUid,
@@ -412,13 +510,6 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
})
);
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
const isGrpcRequest = itemCopy.type === 'grpc-request';
const isWsRequest = itemCopy.type === 'ws-request';
@@ -921,16 +1012,28 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
const item = findItemInCollection(collection, itemUid);
if (item) {
const parentDirectoryItem = findParentItemInCollection(collection, itemUid) || collection;
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type)
.then(() => {
.then(async () => {
// Reorder items in parent directory after deletion
if (parentDirectoryItem.items) {
const directoryItemsWithoutDeletedItem = parentDirectoryItem.items.filter((i) => i.uid !== itemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({
items: directoryItemsWithoutDeletedItem
});
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
resolve();
})
.catch((error) => reject(error));
} else {
return reject(new Error('Unable to locate item'));
}
return;
});
};
@@ -1356,7 +1459,7 @@ export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
@@ -1373,6 +1476,18 @@ export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
const runtimeVariables = collectionCopy.runtimeVariables;
try {
const promptVariables = await extractPromptVariablesForRequest(itemCopy, collectionCopy);
if (promptVariables) {
collectionCopy.promptVariables = promptVariables;
}
} catch (error) {
if (error === 'cancelled') {
return resolve(); // Resolve without error if user cancels prompt
}
return reject(error);
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('grpc:load-methods-reflection', {
@@ -1603,12 +1718,185 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
});
};
export const mergeAndPersistEnvironment =
({ persistentEnvVariables, collectionUid }) =>
(_dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
/**
* Update a variable value in its detected scope (inline editing)
* @param {string} variableName - Name of the variable to update
* @param {string} newValue - New value for the variable
* @param {Object} scopeInfo - Scope information from getVariableScope()
* @param {string} collectionUid - Collection UID
*/
export const updateVariableInScope = (variableName, newValue, scopeInfo, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!scopeInfo || !variableName) {
return reject(new Error('Invalid scope information or variable name'));
}
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
try {
const { type, data } = scopeInfo;
// Handle read-only variables early
if (type === 'process.env') {
toast.error('Process environment variables cannot be edited');
return reject(new Error('Process environment variables are read-only'));
}
if (type === 'runtime') {
toast.error('Runtime variables are set by scripts and cannot be edited');
return reject(new Error('Runtime variables are read-only'));
}
// Validate collection for non-global scopes
if (type !== 'global' && !collection) {
return reject(new Error('Collection not found'));
}
switch (type) {
case 'environment': {
const { environment, variable } = data;
if (!variable) {
return reject(new Error('Variable not found'));
}
const updatedVariables = environment.variables.map((v) => (v.uid === variable.uid ? { ...v, value: newValue } : v));
return dispatch(saveEnvironment(updatedVariables, environment.uid, collectionUid))
.then(() => {
toast.success(`Variable "${variableName}" updated`);
})
.then(resolve)
.catch(reject);
}
case 'collection': {
const { variable } = data;
if (variable) {
// Update existing variable in draft
dispatch(updateCollectionVar({
collectionUid,
type: 'request',
var: { ...variable, value: newValue }
}));
} else {
// Create new variable in draft with actual values
dispatch(addCollectionVar({
collectionUid,
type: 'request',
var: { name: variableName, value: newValue, enabled: true }
}));
}
// Save collection root to persist the changes
return dispatch(saveCollectionRoot(collectionUid))
.then(resolve)
.catch(reject);
}
case 'folder': {
const { folder, variable } = data;
if (variable) {
// Update existing variable in draft
dispatch(updateFolderVar({
collectionUid,
folderUid: folder.uid,
type: 'request',
var: { ...variable, value: newValue }
}));
} else {
// Create new variable in draft with actual values
dispatch(addFolderVar({
collectionUid,
folderUid: folder.uid,
type: 'request',
var: { name: variableName, value: newValue, enabled: true }
}));
}
// Save folder root to persist the changes
return dispatch(saveFolderRoot(collectionUid, folder.uid))
.then(resolve)
.catch(reject);
}
case 'request': {
const { item, variable } = data;
if (variable) {
// Update existing variable in draft
dispatch(updateVar({
collectionUid,
itemUid: item.uid,
type: 'request',
var: { ...variable, value: newValue }
}));
} else {
// Create new variable in draft with actual values
dispatch(addVar({
collectionUid,
itemUid: item.uid,
type: 'request',
var: { name: variableName, value: newValue, local: false, enabled: true }
}));
}
// Save request to persist the changes
return dispatch(saveRequest(item.uid, collectionUid, true))
.then(resolve)
.catch(reject);
}
case 'global': {
const globalEnvironments = state.globalEnvironments?.globalEnvironments || [];
const activeGlobalEnvUid = state.globalEnvironments?.activeGlobalEnvironmentUid;
if (!activeGlobalEnvUid) {
return reject(new Error('No active global environment'));
}
const environment = globalEnvironments.find((env) => env.uid === activeGlobalEnvUid);
if (!environment) {
return reject(new Error('Global environment not found'));
}
const variable = environment.variables.find((v) => v.name === variableName && v.enabled);
if (!variable) {
return reject(new Error('Variable not found'));
}
const updatedVariables = environment.variables.map((v) =>
v.uid === variable.uid ? { ...v, value: newValue } : v);
return dispatch(saveGlobalEnvironment({ variables: updatedVariables, environmentUid: activeGlobalEnvUid }))
.then(() => {
toast.success(`Variable "${variableName}" updated`);
})
.then(resolve)
.catch(reject);
}
default:
return reject(new Error(`Unknown scope type: ${type}`));
}
} catch (error) {
toast.error(`Failed to update variable: ${error.message}`);
reject(error);
}
});
};
export const mergeAndPersistEnvironment
= ({ persistentEnvVariables, collectionUid }) =>
(_dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));

View File

@@ -1728,6 +1728,7 @@ export const collectionsSlice = createSlice({
addVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
const varData = action.payload.var || {};
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
@@ -1741,10 +1742,10 @@ export const collectionsSlice = createSlice({
item.draft.request.vars.req = item.draft.request.vars.req || [];
item.draft.request.vars.req.push({
uid: uuid(),
name: '',
value: '',
local: false,
enabled: true
name: varData.name || '',
value: varData.value || '',
local: varData.local === true,
enabled: varData.enabled !== false
});
} else if (type === 'response') {
item.draft.request.vars = item.draft.request.vars || {};
@@ -2065,6 +2066,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
const type = action.payload.type;
const varData = action.payload.var || {};
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
@@ -2073,9 +2075,9 @@ export const collectionsSlice = createSlice({
const vars = get(folder, 'draft.request.vars.req', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
name: varData.name || '',
value: varData.value || '',
enabled: varData.enabled !== false
});
set(folder, 'draft.request.vars.req', vars);
} else if (type === 'response') {
@@ -2270,6 +2272,7 @@ export const collectionsSlice = createSlice({
addCollectionVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
const varData = action.payload.var || {};
if (collection) {
if (!collection.draft) {
collection.draft = {
@@ -2280,9 +2283,9 @@ export const collectionsSlice = createSlice({
const vars = get(collection, 'draft.root.request.vars.req', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
name: varData.name || '',
value: varData.value || '',
enabled: varData.enabled !== false
});
set(collection, 'draft.root.request.vars.req', vars);
} else if (type === 'response') {
@@ -2291,6 +2294,7 @@ export const collectionsSlice = createSlice({
uid: uuid(),
name: '',
value: '',
local: false,
enabled: true
});
set(collection, 'draft.root.request.vars.res', vars);

View File

@@ -117,6 +117,13 @@ export const tabsSlice = createSlice({
tab.requestPaneWidth = action.payload.requestPaneWidth;
}
},
updateRequestPaneTabHeight: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.requestPaneHeight = action.payload.requestPaneHeight;
}
},
updateRequestPaneTab: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
@@ -218,6 +225,7 @@ export const {
focusTab,
switchTab,
updateRequestPaneTabWidth,
updateRequestPaneTabHeight,
updateRequestPaneTab,
updateResponsePaneTab,
updateResponsePaneScrollPosition,

View File

@@ -288,10 +288,18 @@ const darkTheme = {
variable: {
valid: 'rgb(11 178 126)',
invalid: '#f06f57',
prompt: '#3D8DF5',
info: {
color: '#ce9178',
bg: 'rgb(48,48,49)',
boxShadow: 'rgb(0 0 0 / 36%) 0px 2px 8px'
color: '#FFFFFF',
bg: '#343434',
boxShadow: 'rgb(0 0 0 / 36%) 0px 2px 8px',
editorBg: '#292929',
iconColor: '#989898',
editorBorder: '#3D3D3D',
editorFocusBorder: '#CCCCCC',
editableDisplayHoverBg: 'rgba(255,255,255,0.03)',
border: '#4F4F4F',
editorBorder: '#3D3D3D'
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.18)',
@@ -465,6 +473,13 @@ const darkTheme = {
}
}
},
deprecationWarning: {
bg: 'rgba(250, 83, 67, 0.1)',
border: 'rgba(250, 83, 67, 0.1)',
icon: '#FA5343',
text: '#B8B8B8'
},
examples: {
buttonBg: '#F59E0B1A',
buttonColor: '#F59E0B',

View File

@@ -289,10 +289,18 @@ const lightTheme = {
variable: {
valid: '#047857',
invalid: 'rgb(185, 28, 28)',
prompt: '#186ADE',
info: {
color: 'rgb(52, 52, 52)',
bg: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.45)'
color: '#343434',
bg: '#FFFFFF',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.45)',
editorBg: '#F7F7F7',
iconColor: '#989898',
editorBorder: '#EFEFEF',
editorFocusBorder: '#989898',
editableDisplayHoverBg: 'rgba(0,0,0,0.02)',
border: '#EFEFEF',
editorBorder: '#EFEFEF'
}
},
searchLineHighlightCurrent: 'rgba(120,120,120,0.10)',
@@ -472,6 +480,13 @@ const lightTheme = {
}
}
},
deprecationWarning: {
bg: 'rgba(217, 31, 17, 0.1)',
border: 'rgba(217, 31, 17, 0.1)',
icon: '#D91F11',
text: '#343434'
},
examples: {
buttonBg: '#D977061A',
buttonColor: '#D97706',

View File

@@ -40,4 +40,4 @@ export const resolveInheritedAuth = (item, collection) => {
...mergedRequest,
auth: effectiveAuth
};
};
};

View File

@@ -1,4 +1,4 @@
import { resolveInheritedAuth } from './auth-utils';
import { resolveInheritedAuth } from './index';
jest.mock('utils/collections/index', () => ({
getTreePathFromCollectionToItem: (collection, item) => {
@@ -76,4 +76,4 @@ describe('auth-utils.resolveInheritedAuth', () => {
expect(resolved.auth.mode).toBe('basic');
expect(resolved.auth.basic.username).toBe('override');
});
});
});

View File

@@ -7,20 +7,27 @@
*/
import { interpolate } from '@usebruno/common';
import { getVariableScope, isVariableSecret, getAllVariables } from 'utils/collections';
import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions';
import store from 'providers/ReduxStore';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { MaskedEditor } from 'utils/common/masked-editor';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { variableNameRegex } from 'utils/common/regex';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
const COPY_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
const CHECKMARK_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
`;
@@ -29,43 +36,100 @@ const COPY_SUCCESS_COLOR = '#22c55e';
export const COPY_SUCCESS_TIMEOUT = 1000;
const getCopyButton = (variableValue) => {
// Editor height constraints
const EDITOR_MIN_HEIGHT = 1.75;
const EDITOR_MAX_HEIGHT = 11.125;
/**
* Calculate editor height based on content, clamped between min and max
* @param {number} contentHeight - The actual content height from CodeMirror
* @returns {number} The clamped height value
*/
const calculateEditorHeight = (contentHeight) => {
const contentHeightRem = contentHeight / 16;
return Math.min(Math.max(contentHeightRem, EDITOR_MIN_HEIGHT), EDITOR_MAX_HEIGHT);
};
const EYE_ICON_SVG = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
const EYE_OFF_ICON_SVG = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
const getScopeLabel = (scopeType) => {
const labels = {
'global': 'Global',
'environment': 'Environment',
'collection': 'Collection',
'folder': 'Folder',
'request': 'Request',
'runtime': 'Runtime',
'process.env': 'Process Env',
'undefined': 'Undefined'
};
return labels[scopeType] || scopeType;
};
// Get the masked display text based on the value length
const getMaskedDisplay = (value) => {
const contentLength = (value || '').length;
return contentLength > 0 ? '*'.repeat(contentLength) : '';
};
// Update the value display based on the secret and masked state
const updateValueDisplay = (valueDisplay, value, isSecret, isMasked, isRevealed) => {
if ((isSecret || isMasked) && !isRevealed) {
valueDisplay.textContent = getMaskedDisplay(value);
} else {
valueDisplay.textContent = value || '';
}
};
// Check if the raw value contains references to secret variables
const containsSecretVariableReferences = (rawValue, collection, item) => {
if (!rawValue || typeof rawValue !== 'string') {
return false;
}
// Match all variable references like {{varName}}
const variableReferencePattern = /\{\{([^}]+)\}\}/g;
const matches = rawValue.matchAll(variableReferencePattern);
for (const match of matches) {
const referencedVarName = match[1].trim();
// Get scope info for the referenced variable
const referencedScopeInfo = getVariableScope(referencedVarName, collection, item);
// Check if the referenced variable is a secret
if (referencedScopeInfo && isVariableSecret(referencedScopeInfo)) {
return true;
}
}
return false;
};
const getCopyButton = (variableValue, onCopyCallback) => {
const copyButton = document.createElement('button');
copyButton.className = 'copy-button';
copyButton.style.backgroundColor = 'transparent';
copyButton.style.border = 'none';
copyButton.style.color = 'inherit';
copyButton.style.cursor = 'pointer';
copyButton.style.padding = '2px';
copyButton.style.opacity = '0.7';
copyButton.style.transition = 'opacity 0.2s ease';
copyButton.style.display = 'flex';
copyButton.style.alignItems = 'center';
copyButton.style.justifyContent = 'center';
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
copyButton.type = 'button';
let isCopied = false;
copyButton.addEventListener('mouseenter', () => {
if (isCopied) {
return;
}
copyButton.style.opacity = '1';
});
copyButton.addEventListener('mouseleave', () => {
if (isCopied) {
return;
}
copyButton.style.opacity = '0.7';
});
copyButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
// Prevent clicking if showing success checkmark
if (isCopied) {
@@ -77,7 +141,6 @@ const getCopyButton = (variableValue) => {
.then(() => {
isCopied = true;
copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT;
copyButton.style.opacity = '1';
copyButton.style.color = COPY_SUCCESS_COLOR;
copyButton.style.cursor = 'default';
copyButton.classList.add('copy-success');
@@ -85,11 +148,15 @@ const getCopyButton = (variableValue) => {
setTimeout(() => {
isCopied = false;
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
copyButton.style.opacity = '0.7';
copyButton.style.color = 'inherit';
copyButton.style.color = '#989898';
copyButton.style.cursor = 'pointer';
copyButton.classList.remove('copy-success');
}, COPY_SUCCESS_TIMEOUT);
// Call callback if provided
if (onCopyCallback) {
onCopyCallback();
}
})
.catch((err) => {
console.error('Failed to copy to clipboard:', err.message);
@@ -99,37 +166,380 @@ const getCopyButton = (variableValue) => {
return copyButton;
};
export const renderVarInfo = (token, options, cm, pos) => {
export const renderVarInfo = (token, options) => {
// Extract variable name and value based on token
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
if (variableValue === undefined) {
// Don't show popover if we can't extract a variable name or if it's empty/whitespace
if (!variableName || !variableName.trim()) {
return;
}
const into = document.createElement('div');
const collection = options.collection;
const item = options.item;
const contentDiv = document.createElement('div');
contentDiv.style.display = 'flex';
contentDiv.style.alignItems = 'center';
contentDiv.style.gap = '8px';
contentDiv.className = 'info-content';
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
descriptionDiv.style.flex = '1';
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
descriptionDiv.appendChild(document.createTextNode('*****'));
// Check if this is a process.env variable (starts with "process.env.")
let scopeInfo;
if (variableName.startsWith('process.env.')) {
scopeInfo = {
type: 'process.env',
value: variableValue || '',
data: null
};
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
// Detect variable scope
scopeInfo = getVariableScope(variableName, collection, item);
// If variable doesn't exist in any scope, determine scope based on context
if (!scopeInfo) {
if (item) {
// Determine if item is a folder or request
const isFolder = item.type === 'folder';
if (isFolder) {
// We're in folder settings - create as folder variable
scopeInfo = {
type: 'folder',
value: '', // Empty value for new variable
data: { folder: item, variable: null } // variable is null since it doesn't exist yet
};
} else {
// We're in a request - create as request variable
scopeInfo = {
type: 'request',
value: '', // Empty value for new variable
data: { item, variable: null } // variable is null since it doesn't exist yet
};
}
} else if (collection) {
// No item context but we have collection - create as collection variable
scopeInfo = {
type: 'collection',
value: '',
data: { collection, variable: null }
};
} else {
// No context at all, show as undefined
scopeInfo = {
type: 'undefined',
value: '',
data: null
};
}
}
}
const copyButton = getCopyButton(variableValue);
// Check if variable is read-only (process.env, runtime, and undefined variables cannot be edited)
const isReadOnly = scopeInfo.type === 'process.env' || scopeInfo.type === 'runtime' || scopeInfo.type === 'undefined';
contentDiv.appendChild(descriptionDiv);
contentDiv.appendChild(copyButton);
into.appendChild(contentDiv);
// Get raw value from scope
const rawValue = scopeInfo.value || '';
// Check if variable should be masked:
const isSecret = scopeInfo.type !== 'undefined' ? isVariableSecret(scopeInfo) : false;
const hasSecretReferences = containsSecretVariableReferences(rawValue, collection, item);
const shouldMaskValue = isSecret || hasSecretReferences;
const isMasked = options.variables?.maskedEnvVariables?.includes(variableName);
const into = document.createElement('div');
into.className = 'bruno-var-info-container';
// Header: Variable name + Scope badge
const header = document.createElement('div');
header.className = 'var-info-header';
const varName = document.createElement('span');
varName.className = 'var-name';
varName.textContent = variableName;
const scopeBadge = document.createElement('span');
scopeBadge.className = 'var-scope-badge';
// Show scope label with indication if it's a new variable
const scopeLabel = scopeInfo ? getScopeLabel(scopeInfo.type) : 'Unknown';
const isNewVariable = scopeInfo && scopeInfo.data && scopeInfo.data.variable === null;
scopeBadge.textContent = isNewVariable ? `${scopeLabel}` : scopeLabel;
header.appendChild(varName);
header.appendChild(scopeBadge);
into.appendChild(header);
// Check if variable name is valid (only for non-process.env variables)
const isValidVariableName = scopeInfo.type === 'process.env' || variableNameRegex.test(variableName);
// Show warning if variable name is invalid
if (!isValidVariableName) {
const warningNote = document.createElement('div');
warningNote.className = 'var-warning-note';
warningNote.textContent = 'Invalid variable name! Variables must only contain alpha-numeric characters, "-", "_", "."';
into.appendChild(warningNote);
// Don't show value or any other content for invalid variable names
return into;
}
// Value container with icons
const valueContainer = document.createElement('div');
valueContainer.className = 'var-value-container';
// Create editable value display/editor (if editable)
if (!isReadOnly && scopeInfo) {
// Handle secret/masked variables state
let isRevealed = false;
// Create display element (shows interpolated value by default)
const valueDisplay = document.createElement('div');
valueDisplay.className = 'var-value-editable-display';
// Mask the displayed value if it contains secrets or references to secrets
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, false);
// Create container for CodeMirror (hidden by default)
const editorContainer = document.createElement('div');
editorContainer.className = 'var-value-editor';
editorContainer.style.display = 'none'; // Hidden initially
// Detect current theme from DOM
const isDarkTheme = document.documentElement.classList.contains('dark');
const cmTheme = isDarkTheme ? 'monokai' : 'default';
// Get all variables for syntax highlighting (but prevent recursive tooltips)
const allVariables = collection ? getAllVariables(collection, item) : {};
// Create CodeMirror instance
const cmEditor = CodeMirror(editorContainer, {
value: rawValue, // Use raw value (e.g., {{echo-host}} not resolved value)
mode: 'brunovariables',
theme: cmTheme,
lineWrapping: true,
lineNumbers: false,
brunoVarInfo: false, // Disable tooltips within the editor to prevent recursion
scrollbarStyle: null,
viewportMargin: Infinity
});
// Setup variable mode for syntax highlighting
defineCodeMirrorBrunoVariablesMode(allVariables, 'text/plain', false, true);
cmEditor.setOption('mode', 'brunovariables');
// Setup autocomplete
const getAllVariablesHandler = () => allVariables;
const autoCompleteOptions = {
getAllVariables: getAllVariablesHandler,
showHintsFor: ['variables']
};
const autoCompleteCleanup = setupAutoComplete(cmEditor, autoCompleteOptions);
// Handle secret/masked variables
let maskedEditor = null;
if (shouldMaskValue || isMasked) {
maskedEditor = new MaskedEditor(cmEditor);
maskedEditor.enable();
}
// Store original value for comparison and track editing state
let originalValue = rawValue;
let isEditing = false;
cmEditor.setOption('extraKeys', {
'Enter': (cm) => {
// Enter: save and blur
cm.getInputField().blur();
},
'Shift-Enter': (cm) => {
// Shift+Enter: insert new line
cm.replaceSelection('\n', 'end');
}
});
// Dynamically adjust editor height as content changes
cmEditor.on('change', () => {
if (isEditing) {
// Use requestAnimationFrame for smoother updates after DOM changes
requestAnimationFrame(() => {
cmEditor.refresh();
// Get height from the actual rendered sizer element (more accurate)
const sizer = cmEditor.getWrapperElement().querySelector('.CodeMirror-sizer');
const contentHeight = sizer ? sizer.clientHeight : cmEditor.getScrollInfo().height;
const newHeight = calculateEditorHeight(contentHeight);
editorContainer.style.height = `${newHeight}rem`;
});
}
});
// Icons container (top-right)
const iconsContainer = document.createElement('div');
iconsContainer.className = 'var-icons';
// Eye toggle button (show if the displayed value is masked)
if (shouldMaskValue || isMasked) {
const toggleButton = document.createElement('button');
toggleButton.className = 'secret-toggle-button';
toggleButton.innerHTML = EYE_ICON_SVG;
toggleButton.type = 'button';
toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
isRevealed = !isRevealed;
// Update icon
toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG;
// Update display mode
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
// Update editor mode
if (maskedEditor) {
isRevealed ? maskedEditor.disable() : maskedEditor.enable();
}
// Refocus the editor if it's currently in edit mode
if (isEditing) {
setTimeout(() => {
cmEditor.focus();
}, 0);
}
});
iconsContainer.appendChild(toggleButton);
}
// Copy button (copy actual value, not masked)
const copyButton = getCopyButton(variableValue || '', () => {
// Refocus the editor if it's currently in edit mode
if (isEditing) {
setTimeout(() => {
cmEditor.focus();
}, 0);
}
});
iconsContainer.appendChild(copyButton);
valueContainer.appendChild(valueDisplay);
valueContainer.appendChild(editorContainer);
valueContainer.appendChild(iconsContainer);
// Click on display to enter edit mode
valueDisplay.addEventListener('click', () => {
if (isEditing) return;
isEditing = true;
valueDisplay.style.display = 'none';
editorContainer.style.display = 'block';
// Focus the editor and ensure proper sizing
setTimeout(() => {
cmEditor.refresh();
cmEditor.focus();
// Set cursor to end of content
const lineCount = cmEditor.lineCount();
const lastLine = cmEditor.getLine(lineCount - 1);
cmEditor.setCursor(lineCount - 1, lastLine ? lastLine.length : 0);
// Adjust height based on content
const contentHeight = cmEditor.getScrollInfo().height;
editorContainer.style.height = `${calculateEditorHeight(contentHeight)}rem`;
}, 0);
});
// Save on blur and return to display mode
cmEditor.on('blur', () => {
const newValue = cmEditor.getValue();
// Switch back to display mode
editorContainer.style.display = 'none';
editorContainer.style.height = `${EDITOR_MIN_HEIGHT}rem`; // Reset to minimum height
valueDisplay.style.display = 'block';
isEditing = false;
if (newValue !== originalValue) {
// Dispatch Redux action to update variable
const dispatch = store.dispatch;
dispatch(updateVariableInScope(variableName, newValue, scopeInfo, collection.uid))
.then(() => {
originalValue = newValue;
// Re-interpolate the new value to show the resolved value in display
const interpolatedValue = interpolate(newValue, allVariables);
// Check if the NEW value contains secret references
const newHasSecretRefs = containsSecretVariableReferences(newValue, collection, item);
const newShouldMask = isSecret || newHasSecretRefs;
updateValueDisplay(valueDisplay, interpolatedValue, newShouldMask, isMasked, isRevealed);
})
.catch((err) => {
console.error('Failed to update variable:', err);
// Revert on error
cmEditor.setValue(originalValue);
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
});
}
});
// Store references for cleanup
valueContainer._cmEditor = cmEditor;
valueContainer._maskedEditor = maskedEditor;
valueContainer._autoCompleteCleanup = autoCompleteCleanup;
} else {
// Read-only display (for runtime, process.env, undefined variables)
let isRevealed = false;
const valueDisplay = document.createElement('div');
valueDisplay.className = 'var-value-display';
// For read-only variables, still check if they reference secrets
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, false);
// Icons container
const iconsContainer = document.createElement('div');
iconsContainer.className = 'var-icons';
// Eye toggle button (for read-only variables that reference secrets or are masked)
if (shouldMaskValue || isMasked) {
const toggleButton = document.createElement('button');
toggleButton.className = 'secret-toggle-button';
toggleButton.innerHTML = EYE_ICON_SVG;
toggleButton.type = 'button';
toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
isRevealed = !isRevealed;
toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG;
updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed);
});
iconsContainer.appendChild(toggleButton);
}
// Copy button (always copy actual value, not masked)
const copyButton = getCopyButton(variableValue || '');
iconsContainer.appendChild(copyButton);
valueContainer.appendChild(valueDisplay);
valueContainer.appendChild(iconsContainer);
// Read-only note
if (scopeInfo.type === 'process.env') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'read-only';
into.appendChild(readOnlyNote);
} else if (scopeInfo.type === 'runtime') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'Set by scripts (read-only)';
into.appendChild(readOnlyNote);
} else if (scopeInfo.type === 'undefined') {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'No active environment';
into.appendChild(readOnlyNote);
}
}
into.appendChild(valueContainer);
return into;
};
@@ -137,6 +547,9 @@ export const renderVarInfo = (token, options, cm, pos) => {
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
// Global state to track active popup
let activePopup = null;
CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) {
if (old && old !== CodeMirror.Init) {
const oldOnMouseOver = cm.state.brunoVarInfo.onMouseOver;
@@ -167,10 +580,12 @@ if (!SERVER_RENDERED) {
const state = cm.state.brunoVarInfo;
const target = e.target || e.srcElement;
// Prevent new tooltips if one is already active
if (target.nodeName !== 'SPAN' || state.hoverTimeout !== undefined) {
return;
}
if (!target.classList.contains('cm-variable-valid')) {
// Show popover for both valid and invalid variables
if (!target.classList.contains('cm-variable-valid') && !target.classList.contains('cm-variable-invalid')) {
return;
}
@@ -210,9 +625,52 @@ if (!SERVER_RENDERED) {
const state = cm.state.brunoVarInfo;
const options = state.options;
const token = cm.getTokenAt(pos, true);
let token = cm.getTokenAt(pos, true);
if (token) {
const brunoVarInfo = renderVarInfo(token, options, cm, pos);
const line = cm.getLine(pos.line);
// Find the opening {{ before the cursor
let start = token.start;
while (start > 0 && !line.substring(start - 2, start).includes('{{')) {
// Stop if we encounter }} - we've gone past the start of our variable
if (line.substring(start - 2, start) === '}}') {
break;
}
start--;
}
if (line.substring(start - 2, start) === '{{') {
start = start - 2;
}
// Find the closing }} after the cursor
let end = token.end;
while (end < line.length && !line.substring(end, end + 2).includes('}}')) {
// Stop if we encounter {{ - we've gone past the end of our variable
if (line.substring(end, end + 2) === '{{') {
break;
}
end++;
}
if (line.substring(end, end + 2) === '}}') {
end = end + 2;
}
// Extract the full variable string including {{ and }}
const fullVariableString = line.substring(start, end);
// Only use the expanded string if it looks like a complete variable
if (fullVariableString.startsWith('{{') && fullVariableString.endsWith('}}')) {
token = {
...token,
string: fullVariableString,
start: start,
end: end
};
}
const brunoVarInfo = renderVarInfo(token, options);
if (brunoVarInfo) {
showPopup(cm, box, brunoVarInfo);
}
@@ -220,11 +678,20 @@ if (!SERVER_RENDERED) {
}
function showPopup(cm, box, brunoVarInfo) {
// If there's already an active popup, remove it first
if (activePopup && activePopup.parentNode) {
activePopup.parentNode.removeChild(activePopup);
activePopup = null;
}
const popup = document.createElement('div');
popup.className = 'CodeMirror-brunoVarInfo';
popup.appendChild(brunoVarInfo);
document.body.appendChild(popup);
// Track this popup as the active one
activePopup = popup;
const popupBox = popup.getBoundingClientRect();
const popupStyle = popup.currentStyle || window.getComputedStyle(popup);
const popupWidth =
@@ -232,28 +699,38 @@ if (!SERVER_RENDERED) {
const popupHeight =
popupBox.bottom - popupBox.top + parseFloat(popupStyle.marginTop) + parseFloat(popupStyle.marginBottom);
let topPos = box.bottom;
if (popupHeight > window.innerHeight - box.bottom - 15 && box.top > window.innerHeight - box.bottom) {
topPos = box.top - popupHeight;
const GAP_REM = 0.5;
const EDGE_MARGIN_REM = 0.9375;
// Position below the trigger by default with gap
let topPos = box.bottom + (GAP_REM * 16);
// Check if there's enough space below; if not, position above
if (popupHeight > window.innerHeight - box.bottom - (EDGE_MARGIN_REM * 16) && box.top > window.innerHeight - box.bottom) {
topPos = box.top - popupHeight - (GAP_REM * 16);
}
// Ensure it doesn't go off the top of the screen
if (topPos < 0) {
topPos = box.bottom;
topPos = box.bottom + (GAP_REM * 16);
}
// make popup appear on top of cursor
if (topPos > 70) {
topPos = topPos - 70;
// Horizontal positioning - align to left of trigger
let leftPos = box.left;
// Ensure it doesn't go off the right edge
if (leftPos + popupWidth > window.innerWidth - (EDGE_MARGIN_REM * 16)) {
leftPos = window.innerWidth - popupWidth - (EDGE_MARGIN_REM * 16);
}
let leftPos = Math.max(0, window.innerWidth - popupWidth - 15);
if (leftPos > box.left) {
leftPos = box.left;
// Ensure it doesn't go off the left edge
if (leftPos < 0) {
leftPos = 0;
}
popup.style.opacity = 1;
popup.style.top = topPos + 'px';
popup.style.left = leftPos + 'px';
popup.style.top = `${topPos / 16}rem`;
popup.style.left = `${leftPos / 16}rem`;
let popupTimeout;
@@ -263,13 +740,41 @@ if (!SERVER_RENDERED) {
const onMouseOut = function () {
clearTimeout(popupTimeout);
popupTimeout = setTimeout(hidePopup, 200);
popupTimeout = setTimeout(hidePopup, 500);
};
const hidePopup = function () {
CodeMirror.off(popup, 'mouseover', onMouseOverPopup);
CodeMirror.off(popup, 'mouseout', onMouseOut);
CodeMirror.off(cm.getWrapperElement(), 'mouseout', onMouseOut);
CodeMirror.off(cm, 'change', onEditorChange);
// Cleanup CodeMirror and MaskedEditor instances
const valueContainer = popup.querySelector('.var-value-container');
if (valueContainer) {
// Cleanup autocomplete
if (valueContainer._autoCompleteCleanup) {
valueContainer._autoCompleteCleanup();
valueContainer._autoCompleteCleanup = null;
}
// Cleanup MaskedEditor
if (valueContainer._maskedEditor) {
valueContainer._maskedEditor.destroy();
valueContainer._maskedEditor = null;
}
// Cleanup CodeMirror
if (valueContainer._cmEditor) {
valueContainer._cmEditor.getWrapperElement().remove();
valueContainer._cmEditor = null;
}
}
// Clear the active popup reference
if (activePopup === popup) {
activePopup = null;
}
if (popup.style.opacity) {
popup.style.opacity = 0;
@@ -283,9 +788,15 @@ if (!SERVER_RENDERED) {
}
};
// Hide popup when user types in the main editor
const onEditorChange = function () {
hidePopup();
};
CodeMirror.on(popup, 'mouseover', onMouseOverPopup);
CodeMirror.on(popup, 'mouseout', onMouseOut);
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
CodeMirror.on(cm, 'change', onEditorChange);
}
}
@@ -302,10 +813,22 @@ export const extractVariableInfo = (str, variables) => {
if (DOUBLE_BRACE_PATTERN.test(str)) {
variableName = str.replace('{{', '').replace('}}', '').trim();
// Don't return empty variable names
if (!variableName) {
return { variableName: undefined, variableValue: undefined };
}
variableValue = interpolate(get(variables, variableName), variables);
} else if (str.startsWith('/:')) {
variableName = str.replace('/:', '').trim();
// Don't return empty variable names
if (!variableName) {
return { variableName: undefined, variableValue: undefined };
}
variableValue = variables?.pathParams?.[variableName];
} else if (str.startsWith('{{') && str.endsWith('}}')) {
// Handle cases like {{}} or {{ }} (empty or whitespace only)
// These don't match the pattern but look like variables
return { variableName: undefined, variableValue: undefined };
} else {
// direct variable reference (e.g., for numeric values in JSON mode or plain variable names)
variableName = str;

View File

@@ -6,6 +6,51 @@ jest.mock('@usebruno/common', () => ({
interpolate: jest.fn()
}));
jest.mock('providers/ReduxStore', () => ({
default: {
dispatch: jest.fn(),
getState: jest.fn()
}
}));
jest.mock('providers/ReduxStore/slices/collections/actions', () => ({
updateVariableInScope: jest.fn()
}));
jest.mock('utils/collections', () => ({
getVariableScope: jest.fn(),
isVariableSecret: jest.fn(),
getAllVariables: jest.fn(),
findEnvironmentInCollection: jest.fn()
}));
jest.mock('utils/common/codemirror', () => ({
defineCodeMirrorBrunoVariablesMode: jest.fn()
}));
jest.mock('utils/common/masked-editor', () => ({
MaskedEditor: jest.fn()
}));
jest.mock('utils/codemirror/autocomplete', () => ({
setupAutoComplete: jest.fn(() => jest.fn())
}));
// Mock CodeMirror
global.CodeMirror = jest.fn((element, options) => {
const mockEditor = {
getValue: jest.fn(() => options.value || ''),
setValue: jest.fn(),
on: jest.fn(),
off: jest.fn(),
refresh: jest.fn(),
focus: jest.fn(),
options: options || {},
getWrapperElement: jest.fn(() => element)
};
return mockEditor;
});
describe('extractVariableInfo', () => {
let mockVariables;
@@ -93,6 +138,24 @@ describe('extractVariableInfo', () => {
variableValue: undefined
});
});
it('should return undefined for empty double brace variables', () => {
const result = extractVariableInfo('{{}}', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
it('should return undefined for whitespace-only double brace variables', () => {
const result = extractVariableInfo('{{ }}', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
});
describe('path parameter format (/:variableName)', () => {
@@ -136,6 +199,24 @@ describe('extractVariableInfo', () => {
variableValue: undefined
});
});
it('should return undefined for empty path parameters', () => {
const result = extractVariableInfo('/:', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
it('should return undefined for whitespace-only path parameters', () => {
const result = extractVariableInfo('/: ', mockVariables);
expect(result).toEqual({
variableName: undefined,
variableValue: undefined
});
});
});
describe('direct variable format', () => {
@@ -258,13 +339,15 @@ describe('renderVarInfo', () => {
jest.useRealTimers();
});
function setupRender(variables) {
const result = renderVarInfo({ string: '{{apiKey}}' }, { variables });
const contentDiv = result.querySelector('.info-content');
const descriptionDiv = contentDiv.querySelector('.info-description');
const copyButton = contentDiv.querySelector('.copy-button');
function setupRender(variables, collection = null, item = null) {
const result = renderVarInfo({ string: '{{apiKey}}' }, { variables, collection, item });
if (!result) return { result: null, containerDiv: null, valueDisplay: null, copyButton: null };
return { result, contentDiv, descriptionDiv, copyButton };
const containerDiv = result;
const valueDisplay = containerDiv.querySelector('.var-value-editable-display') || containerDiv.querySelector('.var-value-display');
const copyButton = containerDiv.querySelector('.copy-button');
return { result, containerDiv, valueDisplay, copyButton };
}
describe('popup functionality', () => {
@@ -275,18 +358,18 @@ describe('renderVarInfo', () => {
});
it('should create a popup with the correct variable name and value', () => {
const { descriptionDiv } = setupRender({ apiKey: 'test-value' });
const { valueDisplay } = setupRender({ apiKey: 'test-value' });
expect(descriptionDiv.textContent).toBe('test-value');
expect(valueDisplay.textContent).toBe('test-value');
});
it('should correctly mask the variable value in the popup', () => {
const { descriptionDiv } = setupRender({
const { valueDisplay } = setupRender({
apiKey: 'test-value',
maskedEnvVariables: ['apiKey']
});
expect(descriptionDiv.textContent).toBe('*****');
expect(valueDisplay.textContent).toBe('**********');
});
});
@@ -297,19 +380,19 @@ describe('renderVarInfo', () => {
expect(copyButton).toBeDefined();
});
it('should copy the variable value to the clipboard', async () => {
it('should copy the variable value to the clipboard', () => {
const { copyButton } = setupRender({ apiKey: 'test-value' });
await copyButton.click();
copyButton.click();
expect(clipboardText).toBe('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
});
it('should copy the variable value of masked variables to the clipboard', async () => {
it('should copy the variable value of masked variables to the clipboard', () => {
const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] });
await copyButton.click();
copyButton.click();
expect(clipboardText).toBe('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
@@ -332,10 +415,10 @@ describe('renderVarInfo', () => {
it('should log to the console when the variable value is not copied', async () => {
const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' });
await copyButton.click();
copyButton.click();
// wait for .catch() microtask to run
await Promise.resolve();
await jest.runAllTimersAsync();
expect(clipboardText).toBe('');
expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error');

View File

@@ -0,0 +1,183 @@
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
import { debounce } from 'lodash';
/**
* Marks URLs in the CodeMirror editor with clickable link styling
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} linkify - The LinkifyIt instance for URL detection
* @param {string} linkClass - CSS class name for links
* @param {string} linkHint - Tooltip text for links
*/
function markUrls(editor, linkify, linkClass, linkHint) {
const doc = editor.getDoc();
const text = doc.getValue();
// Clear existing link marks
editor.getAllMarks().forEach((mark) => {
if (mark.className === linkClass) mark.clear();
});
// Find and mark new URLs
const matches = linkify.match(text);
matches?.forEach(({ index, lastIndex, url }) => {
const from = editor.posFromIndex(index);
const to = editor.posFromIndex(lastIndex);
editor.markText(from, to, {
className: linkClass,
attributes: {
'data-url': url,
'title': linkHint
}
});
});
}
/**
* Handles mouse enter events on links to show hover effects
* @param {Event} event - The mouse enter event
* @param {string} linkClass - CSS class name for links
* @param {string} linkHoverClass - CSS class name for hovered links
* @param {Function} updateCmdCtrlClass - Function to update Cmd/Ctrl state
*/
function handleMouseEnter(event, linkClass, linkHoverClass, updateCmdCtrlClass) {
const el = event.target;
if (!el.classList.contains(linkClass)) return;
updateCmdCtrlClass(event);
el.classList.add(linkHoverClass);
// Add hover effect to previous siblings that are also links
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.previousElementSibling;
}
// Add hover effect to next siblings that are also links
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.add(linkHoverClass);
sibling = sibling.nextElementSibling;
}
}
/**
* Handles mouse leave events on links to remove hover effects
* @param {Event} event - The mouse leave event
* @param {string} linkClass - CSS class name for links
* @param {string} linkHoverClass - CSS class name for hovered links
*/
function handleMouseLeave(event, linkClass, linkHoverClass) {
const el = event.target;
el.classList.remove(linkHoverClass);
// Remove hover effect from previous siblings that are also links
let sibling = el.previousElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.previousElementSibling;
}
// Remove hover effect from next siblings that are also links
sibling = el.nextElementSibling;
while (sibling && sibling.classList.contains(linkClass)) {
sibling.classList.remove(linkHoverClass);
sibling = sibling.nextElementSibling;
}
}
/**
* Updates the CSS class on the editor wrapper based on Cmd/Ctrl key state
* @param {Event} event - The keyboard event
* @param {HTMLElement} editorWrapper - The editor wrapper element
* @param {string} cmdCtrlClass - CSS class name for Cmd/Ctrl pressed state
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
*/
function updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed) {
if (isCmdOrCtrlPressed(event)) {
editorWrapper.classList.add(cmdCtrlClass);
} else {
editorWrapper.classList.remove(cmdCtrlClass);
}
}
/**
* Handles click events on links to open them externally
* @param {Event} event - The click event
* @param {string} linkClass - CSS class name for links
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
*/
function handleClick(event, linkClass, isCmdOrCtrlPressed) {
if (!isCmdOrCtrlPressed(event)) return;
if (event.target.classList.contains(linkClass)) {
event.preventDefault();
event.stopPropagation();
const url = event.target.getAttribute('data-url');
if (url) {
window?.ipcRenderer?.openExternal(url);
}
}
}
/**
* Sets up link awareness for a CodeMirror editor instance.
* This enables automatic URL detection, styling, and click-to-open functionality.
* @param {Object} editor - The CodeMirror editor instance
* @param {Object} options - Configuration options (currently unused but reserved for future use)
* @returns {void}
*/
function setupLinkAware(editor, options = {}) {
if (!editor) {
return;
}
// CSS class names and configuration
const cmdCtrlClass = 'cmd-ctrl-pressed';
const linkClass = 'CodeMirror-link';
const linkHoverClass = 'hovered-link';
const linkHint = isMacOS() ? 'Hold Cmd and click to open link' : 'Hold Ctrl and click to open link';
// Helper function to check if Cmd/Ctrl is pressed
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
// Initialize LinkifyIt for URL detection
const linkify = new LinkifyIt();
const editorWrapper = editor.getWrapperElement();
// Create bound versions of event handlers with proper parameters
const boundMarkUrls = () => markUrls(editor, linkify, linkClass, linkHint);
const boundUpdateCmdCtrlClass = (event) => updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed);
const boundHandleClick = (event) => handleClick(event, linkClass, isCmdOrCtrlPressed);
const boundHandleMouseEnter = (event) => handleMouseEnter(event, linkClass, linkHoverClass, boundUpdateCmdCtrlClass);
const boundHandleMouseLeave = (event) => handleMouseLeave(event, linkClass, linkHoverClass);
// Create debounced version of markUrls
const debouncedMarkUrls = debounce(() => {
requestAnimationFrame(boundMarkUrls);
}, 150);
// Initial URL marking
boundMarkUrls();
// Set up event listeners
editor.on('changes', debouncedMarkUrls);
window.addEventListener('keydown', boundUpdateCmdCtrlClass);
window.addEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.addEventListener('click', boundHandleClick);
editorWrapper.addEventListener('mouseover', boundHandleMouseEnter);
editorWrapper.addEventListener('mouseout', boundHandleMouseLeave);
// Cleanup function to remove all event listeners
editor._destroyLinkAware = () => {
editor.off('changes', debouncedMarkUrls);
window.removeEventListener('keydown', boundUpdateCmdCtrlClass);
window.removeEventListener('keyup', boundUpdateCmdCtrlClass);
editorWrapper.removeEventListener('click', boundHandleClick);
editorWrapper.removeEventListener('mouseover', boundHandleMouseEnter);
editorWrapper.removeEventListener('mouseout', boundHandleMouseLeave);
};
}
export { setupLinkAware };

View File

@@ -0,0 +1,591 @@
import { setupLinkAware } from './linkAware';
import LinkifyIt from 'linkify-it';
import { isMacOS } from 'utils/common/platform';
// No need to mock CodeMirror since setupLinkAware works with an existing editor
// Mock linkify-it
jest.mock('linkify-it', () => {
return jest.fn().mockImplementation(() => ({
match: jest.fn()
}));
});
jest.mock('utils/common/platform', () => ({
isMacOS: jest.fn()
}));
// Mock requestAnimationFrame
global.requestAnimationFrame = jest.fn((cb) => cb());
// Mock window.ipcRenderer
global.window = {
...global.window,
ipcRenderer: {
openExternal: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
describe('setupLinkAware', () => {
let mockEditor;
let mockDoc;
let mockWrapperElement;
let mockLinkify;
let mockMark;
let originalTimeout;
let mockSetTimeout;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// Create a Jest mock for setTimeout
mockSetTimeout = jest.spyOn(global, 'setTimeout');
// Store original timeout and mock requestAnimationFrame
originalTimeout = global.setTimeout;
global.requestAnimationFrame = jest.fn((cb) => cb());
// Setup DOM mocks
mockWrapperElement = {
classList: {
add: jest.fn(),
remove: jest.fn()
},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
mockMark = {
clear: jest.fn(),
className: 'CodeMirror-link'
};
mockDoc = {
getValue: jest.fn().mockReturnValue('Check out https://example.com and http://test.org')
};
mockEditor = {
getDoc: jest.fn().mockReturnValue(mockDoc),
getAllMarks: jest.fn().mockReturnValue([mockMark]),
markText: jest.fn(),
posFromIndex: jest.fn().mockImplementation((index) => ({ line: 0, ch: index })),
getWrapperElement: jest.fn().mockReturnValue(mockWrapperElement),
on: jest.fn(),
off: jest.fn(),
_destroyLinkAware: undefined
};
mockLinkify = {
match: jest.fn().mockReturnValue([
{ index: 10, lastIndex: 28, url: 'https://example.com' },
{ index: 33, lastIndex: 48, url: 'http://test.org' }
])
};
LinkifyIt.mockImplementation(() => mockLinkify);
// Mock window and ipcRenderer
global.window = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
ipcRenderer: {
openExternal: jest.fn()
}
};
});
afterEach(() => {
delete global.window;
delete global.requestAnimationFrame;
global.setTimeout = originalTimeout;
mockSetTimeout.mockRestore();
jest.useRealTimers();
});
describe('editor setup and configuration', () => {
it('should set up link awareness on an existing editor', () => {
setupLinkAware(mockEditor);
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
expect(mockEditor.on).toHaveBeenCalledWith('changes', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
});
it('should accept options parameter', () => {
const options = { someOption: true };
setupLinkAware(mockEditor, options);
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
});
it('should return early if editor is null', () => {
const result = setupLinkAware(null);
expect(result).toBeUndefined();
expect(mockEditor.getWrapperElement).not.toHaveBeenCalled();
});
it('should add _destroyLinkAware method to editor', () => {
setupLinkAware(mockEditor);
expect(mockEditor._destroyLinkAware).toBeInstanceOf(Function);
});
});
describe('platform-specific behavior', () => {
it('should use Cmd key hint on macOS', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
expect.objectContaining({
attributes: expect.objectContaining({
title: 'Hold Cmd and click to open link'
})
}));
});
it('should use Ctrl key hint on non-macOS', () => {
isMacOS.mockReturnValue(false);
setupLinkAware(mockEditor);
// Verify that markUrls was called which sets the hint
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
expect.anything(),
expect.objectContaining({
attributes: expect.objectContaining({
title: 'Hold Ctrl and click to open link'
})
}));
});
});
describe('CSS class management', () => {
it('should add cmd-ctrl-pressed class when modifier key is pressed', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const keydownHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keydown')[1];
const mockEvent = { metaKey: true };
keydownHandler(mockEvent);
expect(mockWrapperElement.classList.add).toHaveBeenCalledWith('cmd-ctrl-pressed');
});
it('should remove cmd-ctrl-pressed class when modifier key is released', () => {
isMacOS.mockReturnValue(false);
setupLinkAware(mockEditor);
const keyupHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keyup')[1];
const mockEvent = { ctrlKey: false };
keyupHandler(mockEvent);
expect(mockWrapperElement.classList.remove).toHaveBeenCalledWith('cmd-ctrl-pressed');
});
});
describe('click handling', () => {
it('should open external URL when Cmd+clicking on a link', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
clickHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
expect(global.window.ipcRenderer.openExternal).toHaveBeenCalledWith('https://example.com');
});
it('should not open URL when clicking without modifier key', () => {
setupLinkAware(mockEditor);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: false,
ctrlKey: false,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
}
};
clickHandler(mockEvent);
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
it('should not open URL when clicking on non-link element', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: () => false }
}
};
clickHandler(mockEvent);
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
it('should not open URL when data-url attribute is missing', () => {
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => null
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
clickHandler(mockEvent);
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
});
});
// Test debouncing behavior
describe('debouncing', () => {
it('should debounce URL marking on content changes', () => {
setupLinkAware(mockEditor);
// Clear the calls from initial setup
mockEditor.getAllMarks.mockClear();
requestAnimationFrame.mockClear();
// Simulate multiple rapid content changes
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
changeHandler();
changeHandler();
// With debouncing, setTimeout should be called (lodash debounce uses it internally)
// The exact number may vary, but we should see at least one call
expect(setTimeout).toHaveBeenCalled();
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 150);
// Fast-forward timers
jest.runAllTimers();
// Should only mark URLs once despite multiple rapid changes
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(mockEditor.getAllMarks).toHaveBeenCalledTimes(1);
});
it('should apply link tooltips when marking URLs', () => {
setupLinkAware(mockEditor);
expect(mockEditor.markText).toHaveBeenCalledWith({ line: 0, ch: 10 },
{ line: 0, ch: 28 },
{
className: 'CodeMirror-link',
attributes: {
'data-url': 'https://example.com',
'title': 'Hold Cmd and click to open link'
}
});
});
});
// Test animation frame handling
describe('animation frame handling', () => {
it('should use requestAnimationFrame for URL marking', () => {
setupLinkAware(mockEditor);
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
changeHandler();
jest.runAllTimers();
expect(requestAnimationFrame).toHaveBeenCalled();
});
});
describe('hover behavior', () => {
it('should add hover class on mouseover for link elements', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
previousElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
previousElementSibling: null
},
nextElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn(),
remove: jest.fn()
},
nextElementSibling: null
}
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.previousElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.nextElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
});
it('should not add hover class for non-link elements', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).not.toHaveBeenCalled();
});
it('should remove hover class on mouseout', () => {
setupLinkAware(mockEditor);
const mouseoutHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseout')[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
previousElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
previousElementSibling: null
},
nextElementSibling: {
classList: {
contains: jest.fn().mockReturnValue(true),
remove: jest.fn()
},
nextElementSibling: null
}
};
const mockEvent = { target: mockTarget };
mouseoutHandler(mockEvent);
expect(mockTarget.classList.remove).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.previousElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
expect(mockTarget.nextElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
});
it('should handle multi-span links correctly on hover', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
// Create a mock with a chain of link spans
const mockNestedPrev = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: null
};
const mockPrev = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockNestedPrev
};
const mockNestedNext = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
nextElementSibling: null
};
const mockNext = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
nextElementSibling: mockNestedNext
};
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockPrev,
nextElementSibling: mockNext
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockPrev.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNestedPrev.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNext.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockNestedNext.classList.add).toHaveBeenCalledWith('hovered-link');
});
});
// Test memory cleanup
describe('memory cleanup', () => {
it('should properly clean up all event listeners and marks', () => {
setupLinkAware(mockEditor);
mockEditor._destroyLinkAware();
expect(mockEditor.off).toHaveBeenCalled();
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledTimes(3); // click, mouseover, mouseout
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
});
});
describe('edge cases', () => {
it('should handle missing target in mouse event', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockEvent = { target: null };
// Note: This will throw as the implementation accesses target.classList without null check
expect(() => mouseoverHandler(mockEvent)).toThrow();
});
it('should handle missing ipcRenderer', () => {
delete global.window.ipcRenderer;
isMacOS.mockReturnValue(true);
setupLinkAware(mockEditor);
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
const mockEvent = {
metaKey: true,
target: {
classList: { contains: (className) => className === 'CodeMirror-link' },
getAttribute: () => 'https://example.com'
},
preventDefault: jest.fn(),
stopPropagation: jest.fn()
};
expect(() => clickHandler(mockEvent)).not.toThrow();
});
it('should handle LinkifyIt returning null matches', () => {
mockLinkify.match.mockReturnValue(null);
expect(() => setupLinkAware(mockEditor)).not.toThrow();
// markText may still be called to clear existing marks
});
it('should handle null siblings in mouseover events', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: null,
nextElementSibling: null
};
const mockEvent = { target: mockTarget };
expect(() => mouseoverHandler(mockEvent)).not.toThrow();
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
});
it('should handle non-link siblings in mouseover events', () => {
setupLinkAware(mockEditor);
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
const mockPrev = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockNext = {
classList: {
contains: jest.fn().mockReturnValue(false),
add: jest.fn()
}
};
const mockTarget = {
classList: {
contains: jest.fn().mockReturnValue(true),
add: jest.fn()
},
previousElementSibling: mockPrev,
nextElementSibling: mockNext
};
const mockEvent = { target: mockTarget };
mouseoverHandler(mockEvent);
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
expect(mockPrev.classList.add).not.toHaveBeenCalled();
expect(mockNext.classList.add).not.toHaveBeenCalled();
});
});
});

View File

@@ -1161,11 +1161,12 @@ export const getAllVariables = (collection, item) => {
const pathParams = getPathParams(item);
const { globalEnvironmentVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {} } = collection;
const mergedVariables = {
...folderVariables,
...requestVariables,
...runtimeVariables
...runtimeVariables,
...promptVariables
};
const mergedVariablesGlobal = {
@@ -1174,6 +1175,7 @@ export const getAllVariables = (collection, item) => {
...folderVariables,
...requestVariables,
...runtimeVariables,
...promptVariables
}
const maskedEnvVariables = getEnvironmentVariablesMasked(collection) || [];
@@ -1194,6 +1196,7 @@ export const getAllVariables = (collection, item) => {
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
...promptVariables,
pathParams: {
...pathParams
},
@@ -1206,6 +1209,44 @@ export const getAllVariables = (collection, item) => {
};
};
// Merge headers from collection, folders, and request
export const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
// Add collection headers first
const collectionHeaders = collection?.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
// Add folder headers next, traversing from root to leaf
if (requestTreePath && requestTreePath.length > 0) {
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderHeaders = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'root.request.headers', []);
folderHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
}
}
}
// Add request headers last (they take precedence)
const requestHeaders = request.headers || [];
requestHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
}
});
// Convert Map back to array
return Array.from(headers.values());
};
export const maskInputValue = (value) => {
if (!value || typeof value !== 'string') {
return '';
@@ -1239,15 +1280,21 @@ const mergeVars = (collection, requestTreePath = []) => {
}
});
for (let i of requestTreePath) {
if (!i) {
continue;
}
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
// Check draft first, then fall back to root
const folderRoot = i.draft || i.root;
let vars = get(folderRoot, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
folderVariables[_var.name] = _var.value;
}
});
} else {
let vars = get(i, 'request.vars.req', []);
let vars = i.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
requestVariables[_var.name] = _var.value;
@@ -1472,3 +1519,117 @@ export const getInitialExampleName = (item) => {
counter++;
}
};
// Get the scope and raw value of a variable by checking all scopes in priority order
export const getVariableScope = (variableName, collection, item) => {
if (!variableName || !collection) {
return null;
}
// 1. Check Request Variables (highest priority)
if (item) {
const requestVars = item.draft ? get(item, 'draft.request.vars.req', []) : get(item, 'request.vars.req', []);
const requestVar = requestVars.find((v) => v.name === variableName && v.enabled);
if (requestVar) {
return {
type: 'request',
value: requestVar.value,
data: { item, variable: requestVar }
};
}
}
// 2. Check Folder Variables
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
for (let i = requestTreePath.length - 1; i >= 0; i--) {
const pathItem = requestTreePath[i];
if (!pathItem) {
continue;
}
if (pathItem.type === 'folder') {
// Check draft first, then fall back to root
const folderRoot = pathItem.draft || pathItem.root;
const folderVars = get(folderRoot, 'request.vars.req', []);
const folderVar = folderVars.find((v) => v.name === variableName && v.enabled);
if (folderVar) {
return {
type: 'folder',
value: folderVar.value,
data: { folder: pathItem, variable: folderVar }
};
}
}
}
// 3. Check Environment Variables
if (collection.activeEnvironmentUid) {
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (environment && environment.variables) {
const envVar = environment.variables.find((v) => v.name === variableName && v.enabled);
if (envVar) {
return {
type: 'environment',
value: envVar.value,
data: { environment, variable: envVar }
};
}
}
}
// 4. Check Collection Variables
// Check draft first, then fall back to root
const collectionRoot = (collection.draft && collection.draft.root) || collection.root || {};
const collectionVars = get(collectionRoot, 'request.vars.req', []);
const collectionVar = collectionVars.find((v) => v.name === variableName && v.enabled);
if (collectionVar) {
return {
type: 'collection',
value: collectionVar.value,
data: { collection, variable: collectionVar }
};
}
// 5. Check Global Environment Variables
const { globalEnvironmentVariables = {} } = collection;
if (globalEnvironmentVariables && globalEnvironmentVariables[variableName]) {
return {
type: 'global',
value: globalEnvironmentVariables[variableName],
data: { variableName, value: globalEnvironmentVariables[variableName] }
};
}
// 6. Check Runtime Variables (set during request execution via scripts)
const { runtimeVariables = {} } = collection;
if (runtimeVariables && runtimeVariables[variableName]) {
return {
type: 'runtime',
value: runtimeVariables[variableName],
data: { variableName, value: runtimeVariables[variableName], readonly: true }
};
}
// Process.env variables are not checked here
return null;
};
// Check if a variable is marked as secret
export const isVariableSecret = (scopeInfo) => {
if (!scopeInfo) {
return false;
}
// Only environment variables can be marked as secret
if (scopeInfo.type === 'environment') {
return !!scopeInfo.data.variable?.secret;
}
// Global variables are not checked here
if (scopeInfo.type === 'global') {
return false;
}
return false;
};

View File

@@ -0,0 +1,37 @@
const { describe, it, expect } = require('@jest/globals');
import { mergeHeaders } from './index';
describe('mergeHeaders', () => {
it('should include headers from collection, folder and request (with correct precedence)', () => {
const collection = {
root: {
request: {
headers: [
{ name: 'X-Collection', value: 'c', enabled: true }
]
}
}
};
const folder = {
type: 'folder',
root: {
request: {
headers: [
{ name: 'X-Folder', value: 'f', enabled: true }
]
}
}
};
const request = {
headers: [
{ name: 'X-Request', value: 'r', enabled: true }
]
};
const headers = mergeHeaders(collection, request, [folder]);
const names = headers.map((h) => h.name);
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
});
});

View File

@@ -1,5 +1,6 @@
import get from 'lodash/get';
import { mockDataFunctions } from '@usebruno/common';
import { PROMPT_VARIABLE_TEXT_PATTERN } from '@usebruno/common/utils';
const CodeMirror = require('codemirror');
@@ -30,6 +31,12 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
while ((ch = stream.next()) != null) {
if (ch === '}' && stream.peek() === '}') {
stream.eat('}');
// Prompt variable: starts with '?', no leading/trailing spaces, no braces
if (PROMPT_VARIABLE_TEXT_PATTERN.test(word)) {
return `variable-prompt`;
}
// Check if it's a mock variable (starts with $) and exists in mockDataFunctions
const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1));
const found = isMockVariable || pathFoundInVariables(word, variables);

View File

@@ -243,25 +243,45 @@ export const connectWS = async (item, collection, environment, runtimeVariables,
});
};
export const sendWsRequest = (item, collection, environment, runtimeVariables) => {
return new Promise(async (resolve, reject) => {
const ensureConnection = async () => {
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true });
}
};
const { request } = item.draft ? item.draft : item;
queueWsMessage(item, collection.uid, request.body.ws[0].content)
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState
});
})
.catch((err) => reject(err));
await ensureConnection();
export const sendWsRequest = async (item, collection, environment, runtimeVariables) => {
const ensureConnection = async () => {
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true });
}
};
await ensureConnection();
// Use queueWsMessage helper to queue all messages with proper variable interpolation
const result = await queueWsMessage(item, collection, environment, runtimeVariables, null);
if (result.success) {
return {};
} else {
throw new Error(result.error || 'Failed to queue messages');
}
};
/**
* Queues a message to an existing WebSocket connection with variable interpolation
* @param {Object} item - The request item
* @param {Object} collection - The collection object
* @param {Object} environment - The environment variables
* @param {Object} runtimeVariables - The runtime variables
* @param {string} messageContent - The message content to queue (or null to queue all messages)
* @returns {Promise<Object>} - The result of the queue operation
*/
export const queueWsMessage = async (item, collection, environment, runtimeVariables, messageContent) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:ws:queue-message', {
item,
collection,
environment,
runtimeVariables,
messageContent
}).then(resolve).catch(reject);
});
};
@@ -289,20 +309,6 @@ export const startWsConnection = async (item, collection, environment, runtimeVa
});
};
/**
* Sends a message to an existing WebSocket connection
* @param {string} requestId - The request ID to send a message to
* @param {string} collectionUid - The collection ID the message is for
* @param {*} message - The message
* @returns {Promise<Object>} - The result of the send operation
*/
export const queueWsMessage = async (item, collectionUid, message) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:ws:queue-message', item.uid, collectionUid, message).then(resolve).catch(reject);
});
};
/**
* Sends a message to an existing WebSocket connection
* @param {string} requestId - The request ID to send a message to

View File

@@ -65,7 +65,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",

View File

@@ -546,7 +546,7 @@ const handler = async function (argv) {
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < requestItems.length) {
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
const { pathname } = requestItem;
const { name, pathname } = requestItem;
const start = process.hrtime();
const result = await runSingleRequest(
@@ -576,7 +576,8 @@ const handler = async function (argv) {
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
suitename: pathname.replace('.bru', '')
suitename: pathname.replace('.bru', ''),
name
});
if (reporterSkipAllHeaders) {
@@ -655,6 +656,9 @@ const handler = async function (argv) {
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
// Extract environment name from envVars if available
const environmentName = envVars?.__name__ || null;
const formatKeys = Object.keys(formats);
if (formatKeys && formatKeys.length > 0) {
const outputJson = {
@@ -665,7 +669,7 @@ const handler = async function (argv) {
const reporters = {
'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
'junit': (path) => makeJUnitOutput(results, path),
'html': (path) => makeHtmlOutput(outputJson, path, runCompletionTime),
html: (path) => makeHtmlOutput(outputJson, path, runCompletionTime, environmentName)
}
for (const formatter of Object.keys(formats))

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const { generateHtmlReport } = require('@usebruno/common/runner');
const { CLI_VERSION } = require('../constants');
const makeHtmlOutput = async (results, outputPath, runCompletionTime) => {
const makeHtmlOutput = async (results, outputPath, runCompletionTime, environment = null) => {
let runnerResults = results;
if (!results) {
runnerResults = [];
@@ -16,9 +16,7 @@ const makeHtmlOutput = async (results, outputPath, runCompletionTime) => {
} else if (Array.isArray(results)) {
runnerResults = results;
}
const environment = runnerResults.length > 0 ? runnerResults[0].environment : null;
const htmlString = generateHtmlReport({
runnerResults: runnerResults,
version: `usebruno v${CLI_VERSION}`,

View File

@@ -15,7 +15,7 @@ const makeJUnitOutput = async (results, outputPath) => {
const totalTests = assertionTestCount + testCount;
const suite = {
'@name': result.suitename,
'@name': result.name,
'@errors': 0,
'@failures': 0,
'@skipped': 0,

View File

@@ -25,12 +25,65 @@ const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const { getCACertificates } = require('@usebruno/requests');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
};
const getCACertHostRegex = (domain) => {
return '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
};
/**
* Extract prompt variables from a request
* Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible
* Note: TO BE CALLED ONLY AFTER THE PREPARE REQUEST
*
* @param {*} request - request object built by prepareRequest
* @returns {string[]} An array of extracted prompt variables
*/
const extractPromptVariablesForRequest = ({ request, collection, envVariables, runtimeVariables, processEnvVars, brunoConfig }) => {
const { vars, collectionVariables, folderVariables, requestVariables, ...requestObj } = request;
const allVariables = {
...envVariables,
...collectionVariables,
...folderVariables,
...requestVariables,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
};
const prompts = extractPromptVariables(requestObj);
prompts.push(...extractPromptVariables(allVariables));
const interpolationOptions = {
envVars: envVariables,
runtimeVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert?.domain, interpolationOptions);
if (domain) {
const hostRegex = getCACertHostRegex(domain);
if (request.url.match(hostRegex)) {
prompts.push(...extractPromptVariables(clientCert));
}
}
}
// return unique prompt variables
return Array.from(new Set(prompts));
};
const runSingleRequest = async function (
item,
collectionPath,
@@ -74,6 +127,39 @@ const runSingleRequest = async function (
request = await prepareRequest(item, collection);
// Detect prompt variables before proceeding
const promptVars = extractPromptVariablesForRequest({ request, collection, envVariables, runtimeVariables, processEnvVars, brunoConfig });
if (promptVars.length > 0) {
const errorMsg = `Prompt variables detected in request. CLI execution is not supported for requests with prompt variables. \nPrompts: ${promptVars.join(', ')}`;
console.log(chalk.yellow(stripExtension(relativeItemPathname) + ' Skipped:') + chalk.dim(` (${errorMsg})`));
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: errorMsg,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
}
request.__bruno__executionMode = 'cli';
const scriptingConfig = get(brunoConfig, 'scripts', {});
@@ -172,7 +258,7 @@ const runSingleRequest = async function (
const domain = interpolateString(clientCert?.domain, interpolationOptions);
const type = clientCert?.type || 'cert';
if (domain) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
const hostRegex = getCACertHostRegex(domain);
if (request.url.match(hostRegex)) {
if (type === 'cert') {
try {

View File

@@ -0,0 +1,73 @@
const { describe, it, expect } = require('@jest/globals');
const fs = require('fs');
const mockGenerateHtmlReport = jest.fn(() => '<html>Mock HTML</html>');
jest.mock('@usebruno/common/runner', () => ({
generateHtmlReport: mockGenerateHtmlReport
}));
const makeHtmlOutput = require('../../src/reporters/html');
describe('makeHtmlOutput', () => {
let writeFileSyncSpy;
beforeEach(() => {
mockGenerateHtmlReport.mockClear();
writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should pass environment parameter to generateHtmlReport when provided', async () => {
const mockResults = {
results: [],
summary: {
totalRequests: 0,
passedRequests: 0,
failedRequests: 0,
errorRequests: 0,
skippedRequests: 0,
totalAssertions: 0,
passedAssertions: 0,
failedAssertions: 0,
totalTests: 0,
passedTests: 0,
failedTests: 0
}
};
await makeHtmlOutput(mockResults, '/tmp/test.html', '2024-01-15T14:30:45.123Z', 'production');
expect(mockGenerateHtmlReport).toHaveBeenCalledWith(expect.objectContaining({
environment: 'production'
}));
});
it('should pass null environment when not provided', async () => {
const mockResults = {
results: [],
summary: {
totalRequests: 0,
passedRequests: 0,
failedRequests: 0,
errorRequests: 0,
skippedRequests: 0,
totalAssertions: 0,
passedAssertions: 0,
failedAssertions: 0,
totalTests: 0,
passedTests: 0,
failedTests: 0
}
};
await makeHtmlOutput(mockResults, '/tmp/test.html', '2024-01-15T14:30:45.123Z');
expect(mockGenerateHtmlReport).toHaveBeenCalledWith(expect.objectContaining({
environment: null
}));
});
});

View File

@@ -22,7 +22,7 @@ describe('makeJUnitOutput', () => {
const results = [
{
description: 'description provided',
suitename: 'Tests/Suite A',
name: 'Tests/Suite A',
request: {
method: 'GET',
url: 'https://ima.test'
@@ -47,7 +47,7 @@ describe('makeJUnitOutput', () => {
method: 'GET',
url: 'https://imanother.test'
},
suitename: 'Tests/Suite B',
name: 'Tests/Suite B',
testResults: [
{
lhsExpr: 'res.status',
@@ -98,7 +98,7 @@ describe('makeJUnitOutput', () => {
const results = [
{
description: 'description provided',
suitename: 'Tests/Suite A',
name: 'Tests/Suite A',
request: {
method: 'GET',
url: 'https://ima.test'

View File

@@ -11,3 +11,10 @@ export {
export {
patternHasher
} from './template-hasher';
export {
PROMPT_VARIABLE_TEXT_PATTERN,
PROMPT_VARIABLE_TEMPLATE_PATTERN,
extractPromptVariables,
extractPromptVariablesFromString
} from './prompt-variables';

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from '@jest/globals';
import { extractPromptVariables, extractPromptVariablesFromString } from './prompt-variables';
describe('prompt variable utils', () => {
describe('extractPromptVariablesFromString', () => {
it('should extract prompt variables', () => {
expect(extractPromptVariablesFromString('Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariablesFromString('No prompts here')).toEqual([]);
expect(extractPromptVariablesFromString('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
});
it('should deduplicate prompt variables', () => {
// Strings
expect(extractPromptVariables('{{?world}} prompt here Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string plus another {{?one}}')).toEqual(['prompts', 'one']);
});
});
describe('extractPromptVariables', () => {
it('should extract prompt variables from strings', () => {
expect(extractPromptVariables('Hello {{?world}}')).toEqual(['world']);
expect(extractPromptVariables('No prompts here')).toEqual([]);
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
});
it('should extract prompt variables from objects', () => {
expect(extractPromptVariables({ text: 'Hello {{?world}}' })).toEqual(['world']);
expect(extractPromptVariables({ noPrompt: 'No prompt here' })).toEqual([]);
expect(extractPromptVariables({ prompt1: 'Hello {{?world}}', prompt2: 'Another {{?test}}' })).toEqual(['world', 'test']);
});
it('should extract prompt variables from arrays', () => {
// Strings
expect(extractPromptVariables(['No prompts here', 'Hello {{?world}}'])).toEqual(['world']);
expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?test}} string'])).toEqual(['prompts', 'one', 'test']);
// Objects
expect(extractPromptVariables([{ prompt: 'Hello {{?world}}', noprompt: 'No prompt here' }, { noprompt: '' }])).toEqual(['world']);
// Nested arrays
expect(extractPromptVariables(['Prompt {{?here}}', ['Hello {{?world}}', 'Another {{?test}} string']])).toEqual(['here', 'world', 'test']);
// Mixed data types
expect(extractPromptVariables([{ text: 'Multiple {{?prompts}} in {{?one}} string', noPrompt: 'No prompt here' }, ['Another {{?test}} string', { prompt: '{{?nested}}', no: 'prompt' }]])).toEqual(['prompts', 'one', 'test', 'nested']);
});
it('should not extract prompt variables from invalid template patterns', () => {
expect(extractPromptVariables('Prompt with valid {{?inner space}}')).toEqual(['inner space']);
expect(extractPromptVariables('Prompt with invalid {{? leading space}}')).toEqual([]);
expect(extractPromptVariables('Prompt with invalid {{?trailing space }}')).toEqual([]);
expect(extractPromptVariables('Prompt with invalid {{?{curly brace}}')).toEqual([]);
expect(extractPromptVariables('Prompt with invalid {{?}curly brace}}')).toEqual([]);
expect(extractPromptVariables('Prompt with invalid {{?{curly brace}}}')).toEqual([]);
});
});
});

View File

@@ -0,0 +1,78 @@
/**
* Inner regex pattern for prompt variable names (without braces or `?` prefix)
*
* Pattern: /[^{}\s](?:[^{}]*[^{}\s])?/
*
* Breakdown:
* | Part | Meaning |
* | -------------- | ---------------------------------------------------------- |
* | `[^\s{}]` | First character: not whitespace, `{`, or `}` |
* | `(?:...)?` | Optional non-capturing group (allows single-char names) |
* | `[^{}]*` | Middle characters: any except `{` or `}` (spaces allowed) |
* | `[^\s{}]` | Last character: not whitespace, `{`, or `}` |
*
* This inner pattern is reused in:
* - PROMPT_VARIABLE_TEXT_PATTERN: Matches "?Name" format (with anchors)
* - PROMPT_VARIABLE_PATTERN: Matches "{{?Name}}" format (in templates)
*
* Valid examples: "Name", "Prompt Var", "x"
* Invalid examples: " Name", "Name ", "{Name}", "Na{me}"
*/
const PROMPT_VARIABLE_PATTERN = /[^{}\s](?:[^{}]*[^{}\s])?/;
/**
* Valid examples: "?Name", "?Prompt Var", "?x"
* Invalid examples: "? Name", "?Name ", "?{{Name}}", "?{Name}"
*/
export const PROMPT_VARIABLE_TEXT_PATTERN = new RegExp(`^\\?(${PROMPT_VARIABLE_PATTERN.source})$`);
/**
* Valid matches: "{{?Name}}", "{{?Prompt Var}}", "{{?x}}"
* Invalid: "{{? Name}}", "{{?Name }}", "{{?{Name}}}"
*/
export const PROMPT_VARIABLE_TEMPLATE_PATTERN = new RegExp(`{{\\?(${PROMPT_VARIABLE_PATTERN.source})}}`, 'g');
/**
* Extract prompt variables matching {{?<Prompt Text>}} from a string.
* @param {string} str - The input string.
* @returns {string[]} - An array of extracted prompt variables.
*/
export const extractPromptVariablesFromString = (str: string): string[] => {
const prompts = new Set<string>();
let match;
while ((match = PROMPT_VARIABLE_TEMPLATE_PATTERN.exec(str)) !== null) {
prompts.add(match[1]);
}
return Array.from(prompts);
};
/**
* Extract prompt variables from an object.
* @param {*} obj - The input object.
* @returns {string[]} - An array of extracted prompt variables.
*/
export function extractPromptVariables(obj: any): string[] {
const prompts = new Set<string>();
try {
if (typeof obj === 'string') {
// Extract prompt variables from strings
const extracted = extractPromptVariablesFromString(obj);
extracted.forEach((prompt) => prompts.add(prompt));
} else if (Array.isArray(obj)) {
// Recursively extract from array elements
for (const item of obj) {
const extracted = extractPromptVariables(item);
extracted.forEach((prompt) => prompts.add(prompt));
}
} else if (typeof obj === 'object' && obj !== null) {
// Recursively extract from object properties
for (const key in obj) {
const extracted = extractPromptVariables(obj[key]);
extracted.forEach((prompt) => prompts.add(prompt));
}
}
} catch (error) {
console.error('Error extracting prompt variables:', error);
}
return Array.from(prompts);
}

View File

@@ -20,7 +20,7 @@
},
"dependencies": {
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8",

View File

@@ -3,6 +3,21 @@ import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
// Content type patterns for matching MIME type variants
// These patterns handle structured types with many variants (e.g., application/ld+json, application/vnd.api+json)
// MIME types can contain: letters, numbers, hyphens, dots, and plus signs
const CONTENT_TYPE_PATTERNS = {
// Matches: application/json, application/ld+json, application/vnd.api+json, text/json, etc.
// Pattern: type/([base]+)?suffix where suffix is json
JSON: /^[\w\-.+]+\/([\w\-.+]+\+)?json$/,
// Matches: application/xml, text/xml, application/atom+xml, application/rss+xml, application/xhtml+xml, etc.
// Pattern: type/([base]+)?suffix where suffix is xml
XML: /^[\w\-.+]+\/([\w\-.+]+\+)?xml$/,
// Matches: text/html
// Pattern: type/([base]+)?suffix where suffix is html
HTML: /^[\w\-.+]+\/([\w\-.+]+\+)?html$/
};
const ensureUrl = (url) => {
// removing multiple slashes after the protocol if it exists, or after the beginning of the string otherwise
return url.replace(/([^:])\/{2,}/g, '$1/');
@@ -77,14 +92,28 @@ const getStatusText = (statusCode) => {
return statusTexts[statusCode] || 'Unknown';
};
/**
* Determines the body type based on content-type from OpenAPI spec
* Uses pattern matching to handle various MIME type variants (e.g., application/ld+json, application/vnd.api+json)
* @param {string} contentType - The content-type from OpenAPI spec (object key, e.g., "application/json")
* @returns {string} - The body type (json, xml, html, text)
*/
const getBodyTypeFromContentType = (contentType) => {
if (contentType?.includes('application/json')) {
if (!contentType || typeof contentType !== 'string') {
return 'text';
}
// Normalize: lowercase (object keys may vary in case, but shouldn't have parameters or whitespace)
const normalizedContentType = contentType.toLowerCase();
if (CONTENT_TYPE_PATTERNS.JSON.test(normalizedContentType)) {
return 'json';
} else if (contentType?.includes('application/xml') || contentType?.includes('text/xml')) {
} else if (CONTENT_TYPE_PATTERNS.XML.test(normalizedContentType)) {
return 'xml';
} else if (contentType?.includes('text/html')) {
} else if (CONTENT_TYPE_PATTERNS.HTML.test(normalizedContentType)) {
return 'html';
}
return 'text';
};
@@ -118,6 +147,135 @@ const buildEmptyJsonBody = (bodySchema, visited = new Map()) => {
return _jsonBody;
};
/**
* Extracts or generates an example value from an OpenAPI schema
* Handles objects, arrays, primitives, and explicit examples
* @param {Object} schema - The OpenAPI schema object
* @returns {*} - The example value (object, array, or primitive)
*/
const getExampleFromSchema = (schema) => {
// Check for explicit example first
if (schema.example !== undefined) {
return schema.example;
}
// Handle different schema types
if (schema.type === 'object' || (schema.properties && !schema.type)) {
// Handle object type or schema with properties (even if type is not explicitly set)
return buildEmptyJsonBody(schema);
} else if (schema.type === 'array') {
if (schema.items) {
// If items are objects (either by type or by having properties), create array with one example object
if (schema.items.type === 'object' || schema.items.properties) {
return [buildEmptyJsonBody(schema.items)];
}
// For primitive array items, return array with default value
if (schema.items.type === 'integer' || schema.items.type === 'number') {
return [0];
} else if (schema.items.type === 'boolean') {
return [false];
} else if (schema.items.type === 'string') {
return [''];
}
}
return [];
} else {
// For primitive types, use default values
if (schema.type === 'integer' || schema.type === 'number') {
return 0;
} else if (schema.type === 'boolean') {
return false;
}
return '';
}
};
/**
* Populates request body in Bruno example from a value
* Uses pattern matching to handle various MIME type variants
* @param {Object} params - Parameters object
* @param {Object} params.body - The Bruno request body object to populate
* @param {*} params.requestBodyValue - The request body value to set
* @param {string} params.contentType - Content type (e.g., 'application/json', 'application/ld+json')
*/
const populateRequestBody = ({ body, requestBodyValue, contentType }) => {
if (!requestBodyValue || !contentType || typeof contentType !== 'string') return;
// Normalize: lowercase (content types from OpenAPI spec object keys may vary in case)
const normalizedContentType = contentType.toLowerCase();
if (CONTENT_TYPE_PATTERNS.JSON.test(normalizedContentType)) {
body.mode = 'json';
body.json = typeof requestBodyValue === 'object' ? JSON.stringify(requestBodyValue, null, 2) : requestBodyValue;
} else if (normalizedContentType === 'application/x-www-form-urlencoded') {
body.mode = 'formUrlEncoded';
// Handle form data if needed
} else if (normalizedContentType === 'multipart/form-data') {
body.mode = 'multipartForm';
// Handle multipart form data if needed
} else if (normalizedContentType === 'text/plain') {
body.mode = 'text';
body.text = typeof requestBodyValue === 'object' ? JSON.stringify(requestBodyValue) : String(requestBodyValue);
} else if (CONTENT_TYPE_PATTERNS.XML.test(normalizedContentType)) {
body.mode = 'xml';
body.xml = typeof requestBodyValue === 'object' ? JSON.stringify(requestBodyValue) : String(requestBodyValue);
}
};
/**
* Creates a Bruno example from OpenAPI example data
* @param {Object} params - Parameters object
* @param {Object} params.brunoRequestItem - The base Bruno request item
* @param {*} params.exampleValue - The example value (object, array, or primitive)
* @param {string} params.exampleName - Name of the example
* @param {string} params.exampleDescription - Description of the example
* @param {string|number} params.statusCode - HTTP status code (for response examples)
* @param {string} params.contentType - Content type (e.g., 'application/json')
* @param {*} [params.requestBodyValue] - Optional request body value to populate in the example
* @param {string} [params.requestBodyContentType] - Optional request body content type
* @returns {Object} Bruno example object
*/
const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName, exampleDescription, statusCode, contentType, requestBodyValue = null, requestBodyContentType = null }) => {
const brunoExample = {
uid: uuid(),
itemUid: brunoRequestItem.uid,
name: exampleName,
description: exampleDescription,
type: 'http-request',
request: {
url: brunoRequestItem.request.url,
method: brunoRequestItem.request.method,
headers: [...brunoRequestItem.request.headers],
params: [...brunoRequestItem.request.params],
body: { ...brunoRequestItem.request.body }
},
response: {
status: String(statusCode),
statusText: getStatusText(statusCode),
headers: contentType ? [
{
uid: uuid(),
name: 'Content-Type',
value: contentType,
description: '',
enabled: true
}
] : [],
body: {
type: getBodyTypeFromContentType(contentType),
content: typeof exampleValue === 'object' ? JSON.stringify(exampleValue, null, 2) : exampleValue
}
}
};
// Populate request body if provided
if (requestBodyValue !== null) {
populateRequestBody({ body: brunoExample.request.body, requestBodyValue, contentType: requestBodyContentType });
}
return brunoExample;
};
const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
let _operationObject = request.operationObject;
@@ -325,7 +483,11 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
let mimeType = Object.keys(content)[0];
let body = content[mimeType] || {};
let bodySchema = body.schema;
if (mimeType === 'application/json') {
// Normalize: lowercase (object keys may vary in case)
const normalizedMimeType = typeof mimeType === 'string' ? mimeType.toLowerCase() : '';
if (CONTENT_TYPE_PATTERNS.JSON.test(normalizedMimeType)) {
brunoRequestItem.request.body.mode = 'json';
if (bodySchema && bodySchema.type === 'object') {
let _jsonBody = buildEmptyJsonBody(bodySchema);
@@ -334,7 +496,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
if (bodySchema && bodySchema.type === 'array') {
brunoRequestItem.request.body.json = JSON.stringify([buildEmptyJsonBody(bodySchema.items)], null, 2);
}
} else if (mimeType === 'application/x-www-form-urlencoded') {
} else if (normalizedMimeType === 'application/x-www-form-urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
if (bodySchema && bodySchema.type === 'object') {
each(bodySchema.properties || {}, (prop, name) => {
@@ -347,7 +509,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
});
});
}
} else if (mimeType === 'multipart/form-data') {
} else if (normalizedMimeType === 'multipart/form-data') {
brunoRequestItem.request.body.mode = 'multipartForm';
if (bodySchema && bodySchema.type === 'object') {
each(bodySchema.properties || {}, (prop, name) => {
@@ -361,10 +523,10 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
});
});
}
} else if (mimeType === 'text/plain') {
} else if (normalizedMimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = '';
} else if (mimeType === 'text/xml') {
} else if (CONTENT_TYPE_PATTERNS.XML.test(normalizedMimeType)) {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = '';
}
@@ -391,56 +553,182 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => {
}
// Handle OpenAPI examples from responses and request body
if (_operationObject.responses || _operationObject.requestBody) {
if (_operationObject.responses) {
const examples = [];
// Extract request body examples if they exist
// Unified structure: all request body data is stored as examples with contentType
const requestBodyExamples = [];
/**
* Helper function to create examples with appropriate request body handling
* @param {Object} params - Parameters object
* @param {*} params.responseExampleValue - The response example value
* @param {string} params.exampleName - Name of the example
* @param {string} params.exampleDescription - Description of the example
* @param {string|number} params.statusCode - HTTP status code
* @param {string} params.responseContentType - Response content type
* @param {string} [params.responseExampleKey] - Optional response example key for matching
*/
const createExamplesWithRequestBody = ({ responseExampleValue, exampleName, exampleDescription, statusCode, responseContentType, responseExampleKey = null }) => {
const requestBodyExamplesWithKeys = requestBodyExamples.filter((rb) => rb.key !== null);
const requestBodyExamplesWithoutKeys = requestBodyExamples.filter((rb) => rb.key === null);
// Check if there's a matching request body example by key
const matchingRequestBodyExample = responseExampleKey
? requestBodyExamplesWithKeys.find((rb) => rb.key === responseExampleKey)
: null;
if (matchingRequestBodyExample) {
// Use the matching request body example
examples.push(createBrunoExample({
brunoRequestItem,
exampleValue: responseExampleValue,
exampleName,
exampleDescription,
statusCode,
contentType: responseContentType,
requestBodyValue: matchingRequestBodyExample.value,
requestBodyContentType: matchingRequestBodyExample.contentType
}));
} else if (requestBodyExamplesWithKeys.length > 0) {
// No match found, create all combinations with request body examples that have keys
requestBodyExamplesWithKeys.forEach((rbExample) => {
const combinedExampleName = `${exampleName} (${rbExample.summary || rbExample.key})`;
const combinedExampleDescription = exampleDescription || rbExample.description || '';
examples.push(createBrunoExample({
brunoRequestItem,
exampleValue: responseExampleValue,
exampleName: combinedExampleName,
exampleDescription: combinedExampleDescription,
statusCode,
contentType: responseContentType,
requestBodyValue: rbExample.value,
requestBodyContentType: rbExample.contentType
}));
});
} else if (requestBodyExamplesWithoutKeys.length > 0) {
// Single example or schema - use the first one for all response examples
const rbExample = requestBodyExamplesWithoutKeys[0];
examples.push(createBrunoExample({
brunoRequestItem,
exampleValue: responseExampleValue,
exampleName,
exampleDescription,
statusCode,
contentType: responseContentType,
requestBodyValue: rbExample.value,
requestBodyContentType: rbExample.contentType
}));
} else {
// No request body, create example without request body
examples.push(createBrunoExample({
brunoRequestItem,
exampleValue: responseExampleValue,
exampleName,
exampleDescription,
statusCode,
contentType: responseContentType
}));
}
};
if (_operationObject.requestBody && _operationObject.requestBody.content) {
Object.entries(_operationObject.requestBody.content).forEach(([contentType, content]) => {
if (content.examples) {
// Multiple request body examples
Object.entries(content.examples).forEach(([exampleKey, example]) => {
requestBodyExamples.push({
key: exampleKey,
value: example.value !== undefined ? example.value : example,
summary: example.summary,
description: example.description,
contentType: contentType
});
});
} else if (content.example !== undefined) {
// Single request body example - convert to unified structure
requestBodyExamples.push({
key: null, // No key for single example
value: content.example,
summary: null,
description: null,
contentType: contentType
});
} else if (content.schema) {
// Schema-based request body - convert to unified structure
requestBodyExamples.push({
key: null, // No key for schema
value: getExampleFromSchema(content.schema),
summary: null,
description: null,
contentType: contentType,
isSchema: true
});
}
});
}
// Handle response examples
if (_operationObject.responses) {
Object.entries(_operationObject.responses).forEach(([statusCode, response]) => {
if (response.content) {
Object.entries(response.content).forEach(([contentType, content]) => {
// Handle examples (plural) - multiple named examples
if (content.examples) {
Object.entries(content.examples).forEach(([exampleKey, example]) => {
const exampleName = example.summary || exampleKey || `${statusCode} Response`;
const exampleDescription = example.description || '';
const exampleValue = example.value !== undefined ? example.value : example;
// Create Bruno example
const brunoExample = {
uid: uuid(),
itemUid: brunoRequestItem.uid,
name: exampleName,
description: exampleDescription,
type: 'http-request',
request: {
url: brunoRequestItem.request.url,
method: brunoRequestItem.request.method,
headers: [...brunoRequestItem.request.headers],
params: [...brunoRequestItem.request.params],
body: { ...brunoRequestItem.request.body }
},
response: {
status: String(statusCode),
statusText: getStatusText(statusCode),
headers: [
{
uid: uuid(),
name: 'Content-Type',
value: contentType,
description: '',
enabled: true
}
],
body: {
type: getBodyTypeFromContentType(contentType),
content: typeof example.value === 'object' ? JSON.stringify(example.value, null, 2) : example.value
}
}
};
createExamplesWithRequestBody({
responseExampleValue: exampleValue,
exampleName,
exampleDescription,
statusCode,
responseContentType: contentType,
responseExampleKey: exampleKey
});
});
} else if (content.example !== undefined) {
// Handle example (singular) at content level
const exampleName = `${statusCode} Response`;
const exampleDescription = response.description || '';
examples.push(brunoExample);
createExamplesWithRequestBody({
responseExampleValue: content.example,
exampleName,
exampleDescription,
statusCode,
responseContentType: contentType
});
} else if (content.schema) {
// Handle schema - extract or generate example from schema
const exampleValue = getExampleFromSchema(content.schema);
const exampleName = `${statusCode} Response`;
const exampleDescription = response.description || '';
createExamplesWithRequestBody({
responseExampleValue: exampleValue,
exampleName,
exampleDescription,
statusCode,
responseContentType: contentType
});
}
});
} else {
// Handle responses without content (e.g., 204 No Content)
const exampleName = `${statusCode} Response`;
const exampleDescription = response.description || '';
createExamplesWithRequestBody({
responseExampleValue: '',
exampleName,
exampleDescription,
statusCode,
responseContentType: null
});
}
});
}

View File

@@ -1,5 +1,5 @@
import map from 'lodash/map';
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../common';
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems, isItemARequest } from '../common';
/**
* Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
@@ -178,15 +178,18 @@ export const brunoToPostman = (collection) => {
exec.push(...testsBlock.split('\n'));
}
eventArray.push({
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
requests: {},
exec: exec
}
});
// Only push the event if exec has content
if (exec.length > 0) {
eventArray.push({
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
requests: {},
exec: exec
}
});
}
}
return eventArray;
};
@@ -464,7 +467,7 @@ export const brunoToPostman = (collection) => {
item: generateItemSection(item.items),
...(folderEvents.length ? { event: folderEvents } : {})
};
} else {
} else if (isItemARequest(item)) {
const requestEvents = generateEventSection(item.request);
const postmanItem = {
name: item.name || 'Untitled Request',
@@ -479,7 +482,8 @@ export const brunoToPostman = (collection) => {
return postmanItem;
}
});
return null;
}).filter(Boolean);
};
const collectionToExport = {};
collectionToExport.info = generateInfoSection();

View File

@@ -751,6 +751,11 @@ const searchLanguageByHeader = (headers) => {
};
const getBodyTypeFromContentTypeHeader = (headers) => {
// Check if headers is null, undefined, or not an array
if (!headers || !Array.isArray(headers)) {
return 'text';
}
const contentTypeHeader = headers.find((header) => header.key.toLowerCase() === 'content-type');
if (contentTypeHeader) {
const contentType = contentTypeHeader.value?.toLowerCase();

View File

@@ -7,7 +7,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => {
items: [
{
name: 'Request With Scripts and Tests',
type: 'http',
type: 'http-request',
filename: 'request-with-scripts.bru',
seq: 1,
settings: {

View File

@@ -234,6 +234,32 @@ servers:
expect(result.items[0].root.request.auth.mode).toBe('inherit');
});
});
it('should handle requestBody with empty content object (undefined mimeType)', () => {
const openApiWithEmptyContent = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with empty requestBody content'
paths:
/test:
post:
summary: 'Test endpoint with empty content'
operationId: 'testEndpoint'
requestBody:
content: {}
responses:
'200':
description: 'OK'
servers:
- url: 'https://example.com'
`;
const result = openApiToBruno(openApiWithEmptyContent);
expect(result.items[0].request.body.mode).toBe('none');
expect(result.items[0].request.body.json).toBe(null);
expect(result.items[0].request.body.text).toBe(null);
expect(result.items[0].request.body.xml).toBe(null);
});
});
const openApiCollectionString = `

View File

@@ -53,10 +53,10 @@ describe('OpenAPI with Examples', () => {
const createUserRequest = brunoCollection.items.find((item) => item.name === 'Create a new user');
expect(createUserRequest).toBeDefined();
expect(createUserRequest.examples).toBeDefined();
expect(createUserRequest.examples).toHaveLength(2);
expect(createUserRequest.examples).toHaveLength(4);
// Check response examples
const createdExample = createUserRequest.examples.find((ex) => ex.name === 'User Created');
const createdExample = createUserRequest.examples.find((ex) => ex.name === 'User Created (Valid User)');
expect(createdExample).toBeDefined();
expect(createdExample.response.status).toBe('201');
expect(createdExample.response.statusText).toBe('Created');
@@ -149,7 +149,7 @@ servers:
expect(JSON.parse(example.response.body.content)).toEqual({ message: 'test' });
});
it('should not create examples array if no examples are present', () => {
it('should create examples without specified request body, when response is present', () => {
const openApiWithoutExamples = `
openapi: '3.0.0'
info:
@@ -174,7 +174,11 @@ servers:
const brunoCollection = openApiToBruno(openApiWithoutExamples);
const request = brunoCollection.items[0];
expect(request.examples).toBeUndefined();
expect(request.examples).toHaveLength(1);
const example = request.examples[0];
expect(example.name).toBe('200 Response');
expect(example.description).toBe('OK');
expect(example.response.body.type).toBe('json');
});
it('should support path-based grouping when specified', () => {
@@ -301,4 +305,507 @@ servers:
expect(productsFolder.type).toBe('folder');
expect(productsFolder.items).toHaveLength(1); // GET /products
});
describe('Request Body Examples', () => {
it('should match request body examples by key when response example key matches', () => {
const openApiWithMatchingKeys = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Matching Keys'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
examples:
valid_user:
summary: 'Valid User'
value:
name: 'John Doe'
email: 'john@example.com'
invalid_user:
summary: 'Invalid User'
value:
name: ''
email: 'invalid'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
valid_user:
summary: 'User Created'
value:
id: 123
name: 'John Doe'
invalid_user:
summary: 'Validation Error'
value:
error: 'Invalid input'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithMatchingKeys);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
expect(request.examples).toHaveLength(2);
// Check that matching keys are used
const validUserExample = request.examples.find((ex) => ex.name === 'User Created');
expect(validUserExample).toBeDefined();
expect(validUserExample.request.body.mode).toBe('json');
expect(JSON.parse(validUserExample.request.body.json)).toEqual({
name: 'John Doe',
email: 'john@example.com'
});
expect(JSON.parse(validUserExample.response.body.content)).toEqual({
id: 123,
name: 'John Doe'
});
const invalidUserExample = request.examples.find((ex) => ex.name === 'Validation Error');
expect(invalidUserExample).toBeDefined();
expect(JSON.parse(invalidUserExample.request.body.json)).toEqual({
name: '',
email: 'invalid'
});
expect(JSON.parse(invalidUserExample.response.body.content)).toEqual({
error: 'Invalid input'
});
});
it('should create all combinations when response example keys do not match request body examples', () => {
const openApiWithNonMatchingKeys = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Non-Matching Keys'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
examples:
valid_user:
summary: 'Valid User'
value:
name: 'John Doe'
email: 'john@example.com'
invalid_user:
summary: 'Invalid User'
value:
name: ''
email: 'invalid'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
created:
summary: 'User Created'
value:
id: 123
'400':
description: 'Bad Request'
content:
application/json:
examples:
error:
summary: 'Validation Error'
value:
error: 'Invalid input'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithNonMatchingKeys);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
// Should have 4 examples: 2 response examples × 2 request body examples
expect(request.examples).toHaveLength(4);
// Check combinations for 201 response
const createdWithValid = request.examples.find((ex) => ex.name === 'User Created (Valid User)');
expect(createdWithValid).toBeDefined();
expect(createdWithValid.response.status).toBe('201');
expect(JSON.parse(createdWithValid.request.body.json)).toEqual({
name: 'John Doe',
email: 'john@example.com'
});
const createdWithInvalid = request.examples.find((ex) => ex.name === 'User Created (Invalid User)');
expect(createdWithInvalid).toBeDefined();
expect(createdWithInvalid.response.status).toBe('201');
expect(JSON.parse(createdWithInvalid.request.body.json)).toEqual({
name: '',
email: 'invalid'
});
// Check combinations for 400 response
const errorWithValid = request.examples.find((ex) => ex.name === 'Validation Error (Valid User)');
expect(errorWithValid).toBeDefined();
expect(errorWithValid.response.status).toBe('400');
const errorWithInvalid = request.examples.find((ex) => ex.name === 'Validation Error (Invalid User)');
expect(errorWithInvalid).toBeDefined();
expect(errorWithInvalid.response.status).toBe('400');
});
it('should use single request body example for all response examples', () => {
const openApiWithSingleRequestBody = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Single Request Body'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
example:
name: 'John Doe'
email: 'john@example.com'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
created:
summary: 'User Created'
value:
id: 123
duplicate:
summary: 'Duplicate User'
value:
error: 'User already exists'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithSingleRequestBody);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
expect(request.examples).toHaveLength(2);
// Both examples should have the same request body
const createdExample = request.examples.find((ex) => ex.name === 'User Created');
expect(createdExample).toBeDefined();
expect(createdExample.request.body.mode).toBe('json');
expect(JSON.parse(createdExample.request.body.json)).toEqual({
name: 'John Doe',
email: 'john@example.com'
});
const duplicateExample = request.examples.find((ex) => ex.name === 'Duplicate User');
expect(duplicateExample).toBeDefined();
expect(JSON.parse(duplicateExample.request.body.json)).toEqual({
name: 'John Doe',
email: 'john@example.com'
});
});
it('should use schema-based request body for all response examples', () => {
const openApiWithSchemaRequestBody = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Schema Request Body'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- email
properties:
name:
type: string
example: 'John Doe'
email:
type: string
format: email
example: 'john@example.com'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
created:
summary: 'User Created'
value:
id: 123
error:
summary: 'Error Response'
value:
error: 'Something went wrong'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithSchemaRequestBody);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
expect(request.examples).toHaveLength(2);
// Both examples should have request body generated from schema
const createdExample = request.examples.find((ex) => ex.name === 'User Created');
expect(createdExample).toBeDefined();
expect(createdExample.request.body.mode).toBe('json');
const requestBody = JSON.parse(createdExample.request.body.json);
expect(requestBody).toHaveProperty('name');
expect(requestBody).toHaveProperty('email');
const errorExample = request.examples.find((ex) => ex.name === 'Error Response');
expect(errorExample).toBeDefined();
expect(JSON.parse(errorExample.request.body.json)).toEqual(requestBody);
});
it('should handle request body examples with different content types', () => {
const openApiWithDifferentRequestBodyTypes = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Different Request Body Types'
paths:
/data:
post:
summary: 'Post data'
operationId: 'postData'
requestBody:
required: true
content:
application/json:
examples:
json_data:
summary: 'JSON Data'
value:
message: 'Hello'
text/plain:
examples:
text_data:
summary: 'Text Data'
value: 'Hello World'
responses:
'200':
description: 'OK'
content:
application/json:
examples:
success:
summary: 'Success'
value:
status: 'ok'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithDifferentRequestBodyTypes);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
// Should create combinations: 1 response × 2 request body examples = 2 examples
expect(request.examples).toHaveLength(2);
const jsonExample = request.examples.find((ex) => ex.name === 'Success (JSON Data)');
expect(jsonExample).toBeDefined();
expect(jsonExample.request.body.mode).toBe('json');
expect(JSON.parse(jsonExample.request.body.json)).toEqual({ message: 'Hello' });
const textExample = request.examples.find((ex) => ex.name === 'Success (Text Data)');
expect(textExample).toBeDefined();
expect(textExample.request.body.mode).toBe('text');
expect(textExample.request.body.text).toBe('Hello World');
});
it('should handle mixed matching and non-matching request body examples', () => {
const openApiWithMixedMatching = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Mixed Matching'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
examples:
valid_user:
summary: 'Valid User'
value:
name: 'John Doe'
email: 'john@example.com'
invalid_user:
summary: 'Invalid User'
value:
name: ''
email: 'invalid'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
valid_user:
summary: 'User Created'
value:
id: 123
unmatched:
summary: 'Unmatched Response'
value:
id: 456
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithMixedMatching);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
// Should have: 1 matched (valid_user) + 2 combinations for unmatched (unmatched × 2 request body examples) = 3
expect(request.examples).toHaveLength(3);
// Matched example
const matchedExample = request.examples.find((ex) => ex.name === 'User Created');
expect(matchedExample).toBeDefined();
expect(JSON.parse(matchedExample.request.body.json)).toEqual({
name: 'John Doe',
email: 'john@example.com'
});
// Unmatched combinations
const unmatchedWithValid = request.examples.find((ex) => ex.name === 'Unmatched Response (Valid User)');
expect(unmatchedWithValid).toBeDefined();
const unmatchedWithInvalid = request.examples.find((ex) => ex.name === 'Unmatched Response (Invalid User)');
expect(unmatchedWithInvalid).toBeDefined();
});
it('should not create request body when no request body is defined', () => {
const openApiWithoutRequestBody = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API without Request Body'
paths:
/users:
get:
summary: 'Get users'
operationId: 'getUsers'
responses:
'200':
description: 'OK'
content:
application/json:
examples:
success:
summary: 'Success'
value:
users: []
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithoutRequestBody);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
expect(request.examples).toHaveLength(1);
const example = request.examples[0];
expect(example.request.body.mode).toBe('none');
expect(example.request.body.json).toBeNull();
});
it('should handle request body with singular example and multiple response examples', () => {
const openApiWithSingularExample = `
openapi: '3.0.0'
info:
version: '1.0.0'
title: 'API with Singular Example'
paths:
/users:
post:
summary: 'Create user'
operationId: 'createUser'
requestBody:
required: true
content:
application/json:
example:
name: 'Jane Doe'
email: 'jane@example.com'
responses:
'201':
description: 'Created'
content:
application/json:
examples:
created:
summary: 'User Created'
value:
id: 1
duplicate:
summary: 'Duplicate'
value:
id: 2
'400':
description: 'Bad Request'
content:
application/json:
examples:
error:
summary: 'Error'
value:
error: 'Bad request'
servers:
- url: 'https://api.example.com'
`;
const brunoCollection = openApiToBruno(openApiWithSingularExample);
const request = brunoCollection.items[0];
expect(request.examples).toBeDefined();
expect(request.examples).toHaveLength(3);
// All examples should have the same request body
const requestBodyValue = { name: 'Jane Doe', email: 'jane@example.com' };
request.examples.forEach((example) => {
expect(example.request.body.mode).toBe('json');
expect(JSON.parse(example.request.body.json)).toEqual(requestBodyValue);
});
});
});
});

View File

@@ -62,7 +62,7 @@
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",

View File

@@ -286,6 +286,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
// create environment
ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => {
try {

View File

@@ -41,11 +41,13 @@ const getCertsAndProxyConfig = async ({
httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount;
httpsAgentRequestFields['ca'] = caCertificates || [];
const { promptVariables } = collection;
const brunoConfig = getBrunoConfig(collectionUid, collection);
const interpolationOptions = {
globalEnvironmentVariables,
envVars,
runtimeVariables,
promptVariables,
processEnvVars
};

View File

@@ -11,6 +11,7 @@ const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const { encodeUrl } = require('@usebruno/common').utils;
const { extractPromptVariables } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('@usebruno/requests');
@@ -144,6 +145,7 @@ const configureRequest = async (
request.maxRedirects = 0;
const { promptVariables = {} } = collection;
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
let axiosInstance = makeAxiosInstance({
proxyMode,
@@ -164,7 +166,7 @@ const configureRequest = async (
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
@@ -180,7 +182,7 @@ const configureRequest = async (
}
break;
case 'implicit':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingImplicitGrant({ request: requestCopy, collectionUid }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
@@ -196,7 +198,7 @@ const configureRequest = async (
}
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
@@ -212,7 +214,7 @@ const configureRequest = async (
}
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
@@ -415,7 +417,8 @@ const registerNetworkIpc = (mainWindow) => {
) => {
// run pre-request script
let scriptResult;
const collectionName = collection?.name
const { promptVariables = {}, name: collectionName } = collection;
const requestScript = get(request, 'script.req');
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
@@ -456,7 +459,7 @@ const registerNetworkIpc = (mainWindow) => {
}
// interpolate variables inside request
interpolateVars(request, envVars, runtimeVariables, processEnvVars);
interpolateVars(request, envVars, runtimeVariables, processEnvVars, promptVariables);
if (request.settings?.encodeUrl) {
request.url = encodeUrl(request.url);
@@ -965,6 +968,50 @@ const registerNetworkIpc = (mainWindow) => {
}
}
/**
* Extract prompt variables from a request
* Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible
* Note: TO BE CALLED ONLY AFTER THE PREPARE REQUEST
*
* @param {*} request - request object built by prepareRequest
* @returns {string[]} An array of extracted prompt variables
*/
const extractPromptVariablesForRequest = async ({ request, collection, envVars: collectionEnvironmentVars, runtimeVariables, processEnvVars }) => {
const { globalEnvironmentVariables, collectionVariables, folderVariables, requestVariables, ...requestObj } = request;
const allVariables = {
...globalEnvironmentVariables,
...collectionEnvironmentVars,
...collectionVariables,
...folderVariables,
...requestVariables,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
};
const { interpolationOptions, ...certsAndProxyConfig } = await getCertsAndProxyConfig({
collectionUid: collection.uid,
collection,
request,
envVars: collectionEnvironmentVars,
runtimeVariables,
processEnvVars,
collectionPath: collection.pathname,
globalEnvironmentVariables
});
const prompts = extractPromptVariables(requestObj);
prompts.push(...extractPromptVariables(allVariables));
prompts.push(...extractPromptVariables(certsAndProxyConfig));
// return unique prompt variables
return Array.from(new Set(prompts));
};
// handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
const collectionUid = collection.uid;
@@ -1148,9 +1195,30 @@ const registerNetworkIpc = (mainWindow) => {
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
const promptVars = await extractPromptVariablesForRequest({ request, collection, envVars, runtimeVariables, processEnvVars });
if (promptVars.length > 0) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped due to containing prompt variables',
responseReceived: {
status: 'skipped',
statusText: `Prompt variables detected in request. Runner execution is not supported for requests with prompt variables. \n Promps: ${promptVars.join(', ')}`,
data: null,
responseTime: 0,
headers: null
},
...eventData
});
currentRequestIndex++;
continue;
}
try {
let preRequestScriptResult;
let preRequestError = null;

View File

@@ -1,7 +1,7 @@
const { forOwn, cloneDeep } = require('lodash');
const { interpolate } = require('@usebruno/common');
const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVariables, processEnvVars }) => {
const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVariables, processEnvVars, promptVariables }) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
@@ -9,6 +9,7 @@ const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVa
processEnvVars = processEnvVars || {};
runtimeVariables = runtimeVariables || {};
globalEnvironmentVariables = globalEnvironmentVariables || {};
promptVariables = promptVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = envVars ? cloneDeep(envVars) : {};
@@ -30,6 +31,7 @@ const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVa
...globalEnvironmentVariables,
...envVars,
...runtimeVariables,
...promptVariables,
process: {
env: {
...processEnvVars

View File

@@ -18,7 +18,7 @@ const getRawQueryString = (url) => {
return queryIndex !== -1 ? url.slice(queryIndex) : '';
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}, promptVariables = {}) => {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
@@ -52,6 +52,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
...requestVariables,
...oauth2CredentialVariables,
...runtimeVariables,
...promptVariables,
process: {
env: {
...processEnvVars
@@ -81,6 +82,26 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
request.body = JSON.parse(parsed);
}
// Interpolate WebSocket message body
const isWsRequest = request.mode === 'ws';
if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) {
request.body.ws.forEach((message) => {
if (message && message.content) {
// Try to detect if content is JSON for proper escaping
let isJson = false;
try {
JSON.parse(message.content);
isJson = true;
} catch (e) {
// Not JSON, treat as regular string
}
message.content = _interpolate(message.content, {
escapeJSONStrings: isJson
});
}
});
}
if (typeof contentType === 'string') {
/*

View File

@@ -18,6 +18,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
const collectionRoot = collection?.draft?.root ? get(collection, 'draft.root', {}) : get(collection, 'root', {});
const headers = {};
const url = request.url;
const { promptVariables = {} } = collection;
const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
@@ -28,6 +29,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
mergeVars(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
request.promptVariables = promptVariables;
}
each(get(request, 'headers', []), (h) => {
@@ -49,6 +51,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
processEnvVars,
envVars,
runtimeVariables,
promptVariables,
body: request.body,
protoPath: request.protoPath,
// Add variable properties for interpolation
@@ -68,7 +71,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
let credentials, credentialsId, oauth2Url, debugInfo;
switch (grantType) {
case 'authorization_code':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
@@ -82,7 +85,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
}
break;
case 'client_credentials':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
@@ -96,7 +99,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
}
break;
case 'password':
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables);
({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig }));
grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
@@ -112,7 +115,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable
}
}
interpolateVars(grpcRequest, envVars, runtimeVariables, processEnvVars);
interpolateVars(grpcRequest, envVars, runtimeVariables, processEnvVars, promptVariables);
processHeaders(grpcRequest.headers);
return grpcRequest;

View File

@@ -327,6 +327,7 @@ const prepareRequest = async (item, collection = {}, abortController) => {
mergeAuth(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
request.promptVariables = collection?.promptVariables || {};
}
@@ -463,6 +464,7 @@ const prepareRequest = async (item, collection = {}, abortController) => {
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;
axiosRequest.requestVariables = request.requestVariables;
axiosRequest.promptVariables = request.promptVariables;
axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables;
axiosRequest.oauth2CredentialVariables = request.oauth2CredentialVariables;
axiosRequest.assertions = request.assertions;

View File

@@ -38,6 +38,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
mergeAuth(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
}
each(get(collectionRoot, 'request.headers', []), (h) => {
@@ -65,6 +67,7 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
let wsRequest = {
uid: item.uid,
mode: request.body.mode,
url: request.url,
headers,
processEnvVars,
@@ -276,15 +279,43 @@ const registerWsEventHandlers = (window) => {
}
});
ipcMain.handle('renderer:ws:queue-message', (event, requestId, collectionUid, message) => {
try {
wsClient.queueMessage(requestId, collectionUid, message);
return { success: true };
} catch (error) {
console.error('Error queuing WebSocket message:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('renderer:ws:queue-message',
async (event, { item, collection, environment, runtimeVariables, messageContent }) => {
try {
const itemCopy = cloneDeep(item);
const preparedRequest = await prepareWsRequest(itemCopy, collection, environment, runtimeVariables, {});
// If messageContent is provided, find and queue that specific message (interpolated)
// Otherwise, queue all messages
if (messageContent !== undefined && messageContent !== null) {
// Find the message index in the original request
const originalMessages = itemCopy.draft?.request?.body?.ws || itemCopy.request?.body?.ws || [];
const messageIndex = originalMessages.findIndex((msg) => msg.content === messageContent);
if (messageIndex >= 0 && preparedRequest.body?.ws?.[messageIndex]) {
// Queue the interpolated version of the specific message
wsClient.queueMessage(preparedRequest.uid, collection.uid, preparedRequest.body.ws[messageIndex].content);
} else {
// Message not found in request body, queue as-is (shouldn't happen in normal flow)
wsClient.queueMessage(preparedRequest.uid, collection.uid, messageContent);
}
} else {
// Queue all messages (they are already interpolated by prepareWsRequest -> interpolateVars)
if (preparedRequest.body && preparedRequest.body.ws && Array.isArray(preparedRequest.body.ws)) {
preparedRequest.body.ws
.filter((message) => message && message.content)
.forEach((message) => {
wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content);
});
}
}
return { success: true };
} catch (error) {
console.error('Error queuing WebSocket message:', error);
return { success: false, error: error.message };
}
});
// Send a message to an existing WebSocket connection
ipcMain.handle('renderer:ws:send-message', (event, requestId, collectionUid, message) => {

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge, webUtils } = require('electron');
const { ipcRenderer, contextBridge, webUtils, shell } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
@@ -14,5 +14,6 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
getFilePath(file) {
const path = webUtils.getPathForFile(file);
return path;
}
},
openExternal: (url) => shell.openExternal(url)
});

View File

@@ -3,13 +3,13 @@ const { configureRequest } = require('../../src/ipc/network/index');
describe('index: configureRequest', () => {
it("Should add 'http://' to the URL if no protocol is specified", async () => {
const request = { method: 'GET', url: 'test-domain', body: {} };
await configureRequest(null, null, request, null, null, null, null);
await configureRequest(null, {}, request, null, null, null, null);
expect(request.url).toEqual('http://test-domain');
});
it("Should NOT add 'http://' to the URL if a protocol is specified", async () => {
const request = { method: 'GET', url: 'ftp://test-domain', body: {} };
await configureRequest(null, null, request, null, null, null, null);
await configureRequest(null, {}, request, null, null, null, null);
expect(request.url).toEqual('ftp://test-domain');
});
});

View File

@@ -7,9 +7,10 @@ const { jar: createCookieJar } = require('@usebruno/requests').cookies;
const variableNameRegex = /^[\w-.]*$/;
class Bru {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName) {
constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.promptVariables = promptVariables || {};
this.processEnvVars = cloneDeep(processEnvVars || {});
this.collectionVariables = collectionVariables || {};
this.folderVariables = folderVariables || {};
@@ -134,6 +135,7 @@ class Bru {
...this.requestVariables,
...this.oauth2CredentialVariables,
...this.runtimeVariables,
...this.promptVariables,
process: {
env: {
...this.processEnvVars

View File

@@ -255,6 +255,7 @@ class AssertRuntime {
return [];
}
const promptVariables = request?.promptVariables || {};
const bru = new Bru(
envVariables,
runtimeVariables,
@@ -263,7 +264,10 @@ class AssertRuntime {
collectionVariables,
folderVariables,
requestVariables,
globalEnvironmentVariables
globalEnvironmentVariables,
{},
undefined,
promptVariables
);
const req = new BrunoRequest(request);
const res = createResponseParser(response);

View File

@@ -61,8 +61,9 @@ class ScriptRuntime {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
@@ -234,8 +235,9 @@ class ScriptRuntime {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);

View File

@@ -61,8 +61,9 @@ class TestRuntime {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName);
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);

View File

@@ -35,7 +35,8 @@ class VarsRuntime {
return;
}
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const promptVariables = request?.promptVariables || {};
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables);
const req = new BrunoRequest(request);
const res = createResponseParser(response);

View File

@@ -58,7 +58,12 @@ async function runScriptInNodeVm({
clearTimeout: global.clearTimeout,
clearInterval: global.clearInterval,
setImmediate: global.setImmediate,
clearImmediate: global.clearImmediate
clearImmediate: global.clearImmediate,
Error: global.Error,
TypeError: global.TypeError,
ReferenceError: global.ReferenceError,
SyntaxError: global.SyntaxError,
RangeError: global.RangeError
};
mixinTypedArrays(scriptContext);

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