mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
40 Commits
feature/ht
...
v2.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536b7393db | ||
|
|
172479edad | ||
|
|
486b91894c | ||
|
|
ca8ef36f9f | ||
|
|
7ed474c8ba | ||
|
|
b0405b1e1a | ||
|
|
c2d000e805 | ||
|
|
6aaccabc04 | ||
|
|
daf23c9e2d | ||
|
|
f952688032 | ||
|
|
f429fa94e3 | ||
|
|
fb420fcea4 | ||
|
|
cc3d6a961a | ||
|
|
27c37192b2 | ||
|
|
faa2ef5de2 | ||
|
|
c05d56fd21 | ||
|
|
b4d19ab8ca | ||
|
|
0cedf48e68 | ||
|
|
4e7bc1a351 | ||
|
|
9d3c8b2401 | ||
|
|
39dfd8d360 | ||
|
|
460832f3ed | ||
|
|
50442d960d | ||
|
|
2ac41806a2 | ||
|
|
e9111c0529 | ||
|
|
48a09f6f50 | ||
|
|
e613e4cbcd | ||
|
|
4631eda281 | ||
|
|
3f7ab31b2b | ||
|
|
27a7b623c7 | ||
|
|
95bc670d8c | ||
|
|
6d8f428140 | ||
|
|
ed18cb6d90 | ||
|
|
bb83fbfb9d | ||
|
|
ddfdeda4d6 | ||
|
|
adb0b90457 | ||
|
|
8c7888533a | ||
|
|
2be602d16c | ||
|
|
8ec1925b9f | ||
|
|
d28f2f32e9 |
66
.coderabbit.yaml
Normal file
66
.coderabbit.yaml
Normal 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
44
CODING_STANDARDS.md
Normal 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
48
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
24
packages/bruno-app/src/components/Icons/CloseAll/index.js
Normal file
24
packages/bruno-app/src/components/Icons/CloseAll/index.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 = /\{\{([^}]+)\}\}/;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
44
packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js
Normal file
44
packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js
Normal 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
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
52
packages/bruno-app/src/providers/PromptVariables/index.js
Normal file
52
packages/bruno-app/src/providers/PromptVariables/index.js
Normal 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;
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -40,4 +40,4 @@ export const resolveInheritedAuth = (item, collection) => {
|
||||
...mergedRequest,
|
||||
auth: effectiveAuth
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
183
packages/bruno-app/src/utils/codemirror/linkAware.js
Normal file
183
packages/bruno-app/src/utils/codemirror/linkAware.js
Normal 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 };
|
||||
591
packages/bruno-app/src/utils/codemirror/linkAware.spec.js
Normal file
591
packages/bruno-app/src/utils/codemirror/linkAware.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
37
packages/bruno-app/src/utils/collections/index.spec.js
Normal file
37
packages/bruno-app/src/utils/collections/index.spec.js
Normal 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']));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
73
packages/bruno-cli/tests/reporters/html.spec.js
Normal file
73
packages/bruno-cli/tests/reporters/html.spec.js
Normal 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
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -11,3 +11,10 @@ export {
|
||||
export {
|
||||
patternHasher
|
||||
} from './template-hasher';
|
||||
|
||||
export {
|
||||
PROMPT_VARIABLE_TEXT_PATTERN,
|
||||
PROMPT_VARIABLE_TEMPLATE_PATTERN,
|
||||
extractPromptVariables,
|
||||
extractPromptVariablesFromString
|
||||
} from './prompt-variables';
|
||||
|
||||
57
packages/bruno-common/src/utils/prompt-variables.spec.ts
Normal file
57
packages/bruno-common/src/utils/prompt-variables.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
78
packages/bruno-common/src/utils/prompt-variables.ts
Normal file
78
packages/bruno-common/src/utils/prompt-variables.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -286,6 +286,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// create environment
|
||||
ipcMain.handle('renderer:create-environment', async (event, collectionPathname, name, variables) => {
|
||||
try {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user