Compare commits

...

51 Commits

Author SHA1 Message Date
Anoop M D
d000625c39 release: v1.26.2 2024-08-26 00:47:39 +05:30
lohit
4bdbfb5c0c fix: safe mode validations (#2912) 2024-08-24 14:29:43 +05:30
Jan Monschke
4fbd2f0bdb Self-host Inter font to fix GDPR issue (#750)
* chore: self-host Inter

fixes https://github.com/usebruno/bruno/issues/695

* chore: fix package-lock to include fontsource

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-24 02:24:24 +05:30
Anoop M D
5ae3f0e75a fix: revert pr #1121 - back to jsonlint 2024-08-24 02:13:13 +05:30
Sanjeev Shrestha
b80269b68f Remove deprecated jsonlint package and use upto date package (#1121)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-24 02:05:22 +05:30
Bruno Braga
67de396927 feature: add search results count to CodeMirror (#1498) 2024-08-24 01:49:38 +05:30
Anoop M D
65b80cfd06 chore: fix lint issue 2024-08-24 01:40:09 +05:30
Nikhil569
caa0a22e74 Shorten method name for OPTIONS and DELETE (#588) 2024-08-24 01:30:19 +05:30
Rinku Chaudhari
c7f0335d96 fix: response headers count logic update (#1488) 2024-08-24 01:25:49 +05:30
chrisn
22fab7f599 Update bruno-cli/options-description (#1592)
- Addition to the parameter description for the new tests-only switch

Co-authored-by: Chris Nagel <mail@chrisnagel.de>
2024-08-24 01:19:47 +05:30
Hinnerk Oetting
c0d214f2bc Add documentation to postman import and export (#2274)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-24 01:12:31 +05:30
shawnsarwar
9efbd7377a feat: support relative links in markdown for docs (#2375)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-24 01:01:55 +05:30
Michel Descoteaux
b63cf46734 feat: Add grapqhl in export to postman collection (#2386) 2024-08-24 00:52:15 +05:30
Miguel Hernández
73ac969d35 fix: replace curly braces with colons in OpenAPI path parameters (#2513) (#2533) 2024-08-24 00:49:37 +05:30
tlaloc911
dc21206fc0 fix pre text style (#2545) 2024-08-24 00:45:57 +05:30
Santiago Chiabotto
8f58235e17 added tailwind pointer-events classes to query result filter (#2535) 2024-08-24 00:44:12 +05:30
François Mockers
fd6b3630a5 extract basic OpenAPI links (#2624) 2024-08-24 00:41:45 +05:30
Zhaolin Liang
25e57d2578 bugfix/fix save text response as base64 (#2886) 2024-08-24 00:38:21 +05:30
Niklas Ziermann
cef6f85845 feature(#2839): introduce singular dev script to improve DX (#2840)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-24 00:34:23 +05:30
lohit
5f0e6f13eb fix: quickjs vm handle errors (#2906)
* fix: quickjs vm handle errors
2024-08-23 18:16:43 +05:30
Pragadesh-45
0b9554c8cc Fix/rename-collection-support-wsl (#2892)
* fix: normalize wsl path for rename item

* added isWSLPath, normalizeWslPath

* revert normalize action on actions.js

* added WSL path checking and apply Win UNC normalize
2024-08-23 16:36:54 +05:30
anusreesubash
8b76ecede3 test: added test for self closing tags in xml-json parser (#2891)
Co-authored-by: Anusree Subash <anusree@usebruno.com>
2024-08-23 16:29:41 +05:30
lohit
44d70ca02a fix: boolean, undefined, null values eval in quickjs vm (#2893)
fix: boolean, undeifned, null values in pre-request vars
2024-08-23 16:18:41 +05:30
lohit
4d55b50250 fix: interpolation of multiple interdependent variables (#2899)
fix: interpolation with multiple interdependent variables in input string
2024-08-23 16:13:00 +05:30
Anoop M D
6320a80cbe fix: fix failing tests caused by upgrading to node v20.15.0 2024-08-22 21:04:38 +05:30
Mateusz Pietryga
d1c34bd379 Feat/ Update node version used by project to match Electron (#2673)
* Feat/electron-bump - bump node version to match electron

* Feat/electron-bump - bump node version - documentation

* Feat/electron-bump - bump node version - package-lock.json
2024-08-22 18:13:29 +05:30
Mateusz Pietryga
71ffe1f8d4 Fix: update rollup / fix build warnings (#2711) 2024-08-22 18:12:40 +05:30
Anoop M D
04ccb2f6ee chore: made path param hint easier on the eyes and ux 2024-08-22 18:11:35 +05:30
Anoop M D
99ddd8021c chore: made path param hint easier on the eyes and ux 2024-08-22 17:58:27 +05:30
Krystian Marcisz
ee8e162f3d feat: add hint for Request Path Variables to improve UX (#2873)
* feat: add hint for Request Path Variables to improve UX
2024-08-22 17:40:39 +05:30
Anoop M D
b1a140a4e0 release: v1.26.1 2024-08-22 13:52:38 +05:30
lohit
e4407f3981 chore: updated testbench - primitive data types (#2888)
* fix: getRequestVar shim, boolean values in vm
* chore: updated testbench
2024-08-22 11:37:13 +05:30
Anoop M D
74e75a7da2 fix: fixed ux issues around content indicator being big 2024-08-22 11:33:35 +05:30
lohit
4aff61b665 fix: getRequestVar shim, boolean values in vm (#2887) 2024-08-22 10:10:15 +05:30
Anoop M D
e31c552dee release: v1.26.0 2024-08-21 18:46:55 +05:30
Anoop M D
753a576c3c Feat/safe mode quickjs (#2848)
Safe Mode Sandbox using QuickJS
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: lohit <lohit.jiddimani@gmail.com>
2024-08-21 12:52:49 +05:30
Pragadesh-45
3ad4eda861 Style/assert option bg (#2867)
* remove old theme configs

* style bg manage from Assertion Comp for AssertionOperator
2024-08-21 10:37:59 +05:30
Sushant Kumar
d3e57d0ea6 refactor: Simplify logic around get method color (#2856)
* feat: Use theme provider to get method color

* fix: Use storeTheme instead of theme
2024-08-19 18:40:52 +05:30
Niklas Ziermann
77750ecc0b #2757: Visualize if request body, script or tests have content (#2809) 2024-08-19 18:37:47 +05:30
Chae Jeong Ah
a1783c46ed fix: invalid file path in shell-curl (#2855) 2024-08-19 18:18:39 +05:30
Daniel Roberto
017d2235b8 fix: remove duplicate tailwind classes in RequestNotFound component (#2801) 2024-08-16 20:08:33 +05:30
Adrian
df120787ca fix: remove scope with auth code grant (#2815) 2024-08-16 20:05:33 +05:30
Pragadesh-45
b872fdfe6d Bugfix- Import blank directory Exception (#2845)
* typofix: Loc is required

* handle empty dirpath on import collection

* fix: collection import bug fix

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2024-08-16 17:49:00 +05:30
Sushant Kumar
f0b7bf3430 feat: Reuse dictionary in preferences support page (#2834)
* feat: Re-use dictonary text in Preferences/Support component

* feat: Re-use dictionary text in Preferences/Support component
2024-08-16 15:49:27 +05:30
Chae Jeong Ah
4169bb7ea4 fix: add path params on newHttpRequest (#2843) 2024-08-16 15:46:38 +05:30
Sushant Kumar
74b1527513 feat: Add support for dictionary and use in Welcome page (#2819) 2024-08-14 15:53:04 +05:30
Daniel Roberto
3b8909e301 feat: add hotkey to close all tabs (#2800) 2024-08-14 15:43:32 +05:30
Rinku Chaudhari
1bedfc2046 feat: added request tab context menu (#2183)
* feat: added close menus on the request tab

* feat: added close to the left button

* feat: added new request and clone request buttons

* chore: fix prettier
2024-08-14 15:40:44 +05:30
Timon
8de6b72ab9 Fix/enospc (#2789)
* fix: Handle ENOSPC error from chokidar

Now listens to the error event to check if "ENOSPC" occurrs.
The watcher will then automaticly restart in polling mode, so that
the user still sees his reqeusts / collections.

Fixes: https://github.com/usebruno/bruno/issues/1877

* Add more code comments, add !forcePolling to prevent endless loops and update error message

* fix: Also listen for EMFILE for too many watched files
2024-08-14 15:20:17 +05:30
Joel Wetzell
9d84906f57 add ability for curl to import basic auth (#2778) 2024-08-14 15:18:24 +05:30
lohit
eceb114d6c fix/collection-search-validations unit-tests-fix (#2833)
* fix: updates

* fix: update test title

* fix: removed console
2024-08-14 13:23:00 +05:30
163 changed files with 6838 additions and 20001 deletions

View File

@@ -25,8 +25,14 @@ jobs:
run: |
npm run build --workspace=packages/bruno-common
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
# test
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
@@ -35,12 +41,8 @@ jobs:
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-common
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
@@ -62,6 +64,7 @@ jobs:
run: |
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
- name: Run tests
run: |

2
.nvmrc
View File

@@ -1 +1 @@
v20.9.0
v20.9.0

View File

@@ -37,7 +37,7 @@ Libraries we use
### Dependencies
You would need [Node v18.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
You would need [Node v20.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
## Development
@@ -57,6 +57,9 @@ npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
# run next app (terminal 1)
npm run dev:web

View File

@@ -37,7 +37,7 @@ Bruno 基于 NextJs 和 React 构建。我们使用 Electron 来封装桌面版
### 依赖项
您需要 [Node v18.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我们在这个项目中也使用 npm 工作区_npm workspaces_
您需要 [Node v20.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我们在这个项目中也使用 npm 工作区_npm workspaces_
## 开发

View File

@@ -37,7 +37,7 @@ Bibliotheken die wir benutzen
### Abhängigkeiten
Du benötigst [Node v18.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
Du benötigst [Node v20.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
### Lass uns coden

View File

@@ -37,7 +37,7 @@ Librerías que utilizamos:
### Dependencias
Necesitarás [Node v18.x o la última versión LTS](https://nodejs.org/es) y npm 8.x. Ten en cuenta que utilizamos espacios de trabajo de npm en el proyecto.
Necesitarás [Node v20.x o la última versión LTS](https://nodejs.org/es) y npm 8.x. Ten en cuenta que utilizamos espacios de trabajo de npm en el proyecto.
## Desarrollo

View File

@@ -37,7 +37,7 @@ Les librairies que nous utilisons :
### Dépendances
Vous aurez besoin de [Node v18.x ou la dernière version LTS](https://nodejs.org/en/) et npm 8.x. Nous utilisons aussi les espaces de travail npm (_npm workspaces_) dans ce projet.
Vous aurez besoin de [Node v20.x ou la dernière version LTS](https://nodejs.org/en/) et npm 8.x. Nous utilisons aussi les espaces de travail npm (_npm workspaces_) dans ce projet.
## Développement

View File

@@ -37,7 +37,7 @@ Libraries जिनका हम उपयोग करते हैं
### निर्भरताएँ
आपको [Node v18.x या नवीनतम LTS संस्करण](https://nodejs.org/en/) और npm 8.x की आवश्यकता होगी। हम प्रोजेक्ट में npm वर्कस्पेस का उपयोग करते हैं
आपको [Node v20.x या नवीनतम LTS संस्करण](https://nodejs.org/en/) और npm 8.x की आवश्यकता होगी। हम प्रोजेक्ट में npm वर्कस्पेस का उपयोग करते हैं
## डेवलपमेंट

View File

@@ -37,7 +37,7 @@ Le librerie che utilizziamo sono:
### Dependences
Hai bisogno di [Node v18.x o dell'ultima versione LTS](https://nodejs.org/en/) di npm 8.x. Utilizziamo gli spazi di lavoro npm (_npm workspaces_) in questo progetto.
Hai bisogno di [Node v20.x o dell'ultima versione LTS](https://nodejs.org/en/) di npm 8.x. Utilizziamo gli spazi di lavoro npm (_npm workspaces_) in questo progetto.
### Iniziamo a codificare

View File

@@ -37,7 +37,7 @@ Bruno は Next.js と React で作られています。デスクトップアプ
### 依存関係
[Node v18.x もしくは最新の LTS バージョン](https://nodejs.org/en/)と npm 8.x が必要です。プロジェクトに npm ワークスペースを使用しています。
[Node v20.x もしくは最新の LTS バージョン](https://nodejs.org/en/)と npm 8.x が必要です。プロジェクトに npm ワークスペースを使用しています。
## 開発

View File

@@ -37,7 +37,7 @@ Bruno는 Next.js와 React로 구축되었습니다. 또한, (로컬 컬렉션을
### 의존성
[Node v18.x 혹은 최신 LTS version](https://nodejs.org/en/)과 npm 8.x 버전이 필요합니다. 우리는 이 프로젝트에서 npm workspaces를 사용합니다.
[Node v20.x 혹은 최신 LTS version](https://nodejs.org/en/)과 npm 8.x 버전이 필요합니다. 우리는 이 프로젝트에서 npm workspaces를 사용합니다.
## 개발

View File

@@ -37,7 +37,7 @@ Biblioteki, których używamy
### Zależności
Będziesz potrzebować [Node v18.x lub najnowszej wersji LTS](https://nodejs.org/en/) oraz npm 8.x. W projekcie używamy npm workspaces
Będziesz potrzebować [Node v20.x lub najnowszej wersji LTS](https://nodejs.org/en/) oraz npm 8.x. W projekcie używamy npm workspaces
## Rozwój

View File

@@ -37,7 +37,7 @@ Bibliotecas que utilizamos:
### Dependências
Você precisará do [Node v18.x (ou da versão LTS mais recente)](https://nodejs.org/en/) e do npm na versão 8.x. Nós utilizamos npm workspaces no projeto.
Você precisará do [Node v20.x (ou da versão LTS mais recente)](https://nodejs.org/en/) e do npm na versão 8.x. Nós utilizamos npm workspaces no projeto.
## Desenvolvimento

View File

@@ -37,7 +37,7 @@ Bibliotecile pe care le folosim
### Dependențele
Veți avea nevoie de [Node v18.x sau cea mai recentă versiune LTS](https://nodejs.org/en/) și npm 8.x. Noi folosim spații de lucru npm în proiect
Veți avea nevoie de [Node v20.x sau cea mai recentă versiune LTS](https://nodejs.org/en/) și npm 8.x. Noi folosim spații de lucru npm în proiect
## Dezvoltarea

View File

@@ -37,7 +37,7 @@ Bruno построен с использованием Next.js и React. Мы т
### Зависимости
Вам потребуется [Node v18.x или последняя версия LTS](https://nodejs.org/en/) и npm 8.x. В проекте мы используем рабочие пространства npm
Вам потребуется [Node v20.x или последняя версия LTS](https://nodejs.org/en/) и npm 8.x. В проекте мы используем рабочие пространства npm
### Приступим к коду

View File

@@ -37,7 +37,7 @@ Kullandığımız kütüphaneler
### Bağımlılıklar
[Node v18.x veya en son LTS sürümüne](https://nodejs.org/en/) ve npm 8.x'e ihtiyacınız olacaktır. Projede npm çalışma alanlarını kullanıyoruz
[Node v20.x veya en son LTS sürümüne](https://nodejs.org/en/) ve npm 8.x'e ihtiyacınız olacaktır. Projede npm çalışma alanlarını kullanıyoruz
## Gelişim

View File

@@ -37,7 +37,7 @@ Bruno побудований на Next.js та React. Також для деск
### Залежності
Вам знадобиться [Node v18.x або остання LTS версія](https://nodejs.org/en/) та npm 8.x. Ми використовуєм npm workspaces в цьому проекті
Вам знадобиться [Node v20.x або остання LTS версія](https://nodejs.org/en/) та npm 8.x. Ми використовуєм npm workspaces в цьому проекті
### Починаєм писати код

View File

@@ -37,7 +37,7 @@ Bruno 使用 Next.js 和 React 構建。我們使用 Electron 來封裝及發佈
### 依賴關係
您需要使用 [Node v18.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我們在這個專案中使用 npm 工作區_npm workspaces_
您需要使用 [Node v20.x 或最新的 LTS 版本](https://nodejs.org/en/) 和 npm 8.x。我們在這個專案中使用 npm 工作區_npm workspaces_
## 開發

22892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,17 @@
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@types/jest": "^29.5.11",
"concurrently": "^8.2.2",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"jest": "^29.2.0",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
"ts-jest": "^29.0.5"
},
"scripts": {
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
@@ -47,9 +50,8 @@
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install"
},
"overrides": {
"rollup": "3.2.5"
"rollup":"3.29.4"
},
"dependencies": {
"json-bigint": "^1.0.0",

View File

@@ -12,6 +12,7 @@
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
},
"dependencies": {
"@fontsource/inter": "^5.0.15",
"@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/react-fontawesome": "^0.1.16",
@@ -35,18 +36,19 @@
"graphiql": "^1.5.9",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.1",
"httpsnippet": "^3.0.6",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
"jsonlint": "^1.6.3",
"jsonpath-plus": "^7.2.0",
"jsonlint": "^1.6.3",
"know-your-http-well": "^0.5.0",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mousetrap": "^1.6.5",
"nanoid": "3.3.4",
"next": "12.3.3",

View File

@@ -14,6 +14,24 @@ const StyledWrapper = styled.div`
background: #d2d7db;
}
.CodeMirror-dialog {
overflow: visible;
}
#search-results-count {
display: inline-block;
position: absolute;
top: calc(100% + 1px);
right: 0;
border-width: 0 0 1px 1px;
border-style: solid;
border-color: ${(props) => props.theme.codemirror.border};
padding: 0.1em 0.8em;
background-color: ${(props) => props.theme.codemirror.bg};
color: rgb(102, 102, 102);
white-space: nowrap;
}
textarea.cm-editor {
position: relative;
}

View File

@@ -66,7 +66,9 @@ if (!SERVER_RENDERED) {
'bru.getVar(key)',
'bru.setVar(key,value)',
'bru.deleteVar(key)',
'bru.setNextRequest(requestName)'
'bru.setNextRequest(requestName)',
'bru.getRequestVar(key)',
'bru.sleep(ms)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -109,6 +111,7 @@ export default class CodeEditor extends React.Component {
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.variables = {};
this.searchResultsCountElementId = 'search-results-count';
this.lintOptions = {
esversion: 11,
@@ -155,8 +158,16 @@ export default class CodeEditor extends React.Component {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-F': (cm) => {
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Ctrl-F': (cm) => {
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
Tab: function (cm) {
@@ -308,6 +319,8 @@ export default class CodeEditor extends React.Component {
this.editor.off('change', this._onEdit);
this.editor = null;
}
this._unbindSearchHandler();
}
render() {
@@ -344,4 +357,62 @@ export default class CodeEditor extends React.Component {
}
}
};
/**
* Bind handler to search input to count number of search results
*/
_bindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.addEventListener('input', this._countSearchResults);
}
};
/**
* Unbind handler to search input to count number of search results
*/
_unbindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.removeEventListener('input', this._countSearchResults);
}
};
/**
* Append search results count to search dialog
*/
_appendSearchResultsCount = () => {
const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
if (dialog) {
const searchResultsCount = document.createElement('span');
searchResultsCount.id = this.searchResultsCountElementId;
dialog.appendChild(searchResultsCount);
this._countSearchResults();
}
};
/**
* Count search results and update state
*/
_countSearchResults = () => {
let count = 0;
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput && searchInput.value.length > 0) {
const text = new RegExp(searchInput.value, 'gi');
const matches = this.editor.getValue().match(text);
count = matches ? matches.length : 0;
}
const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`);
if (searchResultsCountElement) {
searchResultsCountElement.innerText = `${count} results`;
}
};
}

View File

@@ -48,7 +48,7 @@ const Docs = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
/>
) : (
<Markdown onDoubleClick={toggleViewMode} content={docs} />
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}
</StyledWrapper>
);

View File

@@ -53,7 +53,7 @@ const Documentation = ({ item, collection }) => {
mode="application/text"
/>
) : (
<Markdown onDoubleClick={toggleViewMode} content={docs} />
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}
</StyledWrapper>
);

View File

@@ -40,10 +40,15 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.dropdown.iconColor};
}
&:hover {
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:disabled {
cursor: not-allowed;
color: gray;
}
&.border-top {
border-top: solid 1px ${(props) => props.theme.dropdown.separator};
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
const DotIcon = ({ width }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={width}
viewBox="0 0 24 24" strokeWidth="1.5"
stroke="currentColor" fill="none" strokeLinecap="round" strokeLinejoin="round"
className='inline-block'
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 7a5 5 0 1 1 -4.995 5.217l-.005 -.217l.005 -.217a5 5 0 0 1 4.995 -4.783z" stroke-width="0" fill="currentColor" />
</svg>
);
};
export default DotIcon;

View File

@@ -69,6 +69,7 @@ const StyledMarkdownBodyWrapper = styled.div`
pre {
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
}
table {

View File

@@ -1,10 +1,15 @@
import MarkdownIt from 'markdown-it';
import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
const md = new MarkdownIt();
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
replaceLink: function (link, env) {
return link.replace(/^\./, collectionPath);
}
};
const Markdown = ({ onDoubleClick, content }) => {
const handleOnClick = (event) => {
const target = event.target;
if (target.tagName === 'A') {
@@ -23,6 +28,8 @@ const Markdown = ({ onDoubleClick, content }) => {
}
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = md.render(content || '');
return (

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import StyledWrapper from './StyledWrapper';
const ModalHeader = ({ title, handleCancel, customHeader }) => (
const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header">
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel ? (
{handleCancel && !hideClose ? (
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
×
</div>
@@ -63,6 +63,7 @@ const Modal = ({
confirmDisabled,
hideCancel,
hideFooter,
hideClose,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
@@ -100,7 +101,12 @@ const Modal = ({
return (
<StyledWrapper className={classes} onClick={onClick ? (e) => onClick(e) : null}>
<div className={`bruno-modal-card modal-${size}`}>
<ModalHeader title={title} handleCancel={() => closeModal({ type: 'icon' })} customHeader={customHeader} />
<ModalHeader
title={title}
hideClose={hideClose}
handleCancel={() => closeModal({ type: 'icon' })}
customHeader={customHeader}
/>
<ModalContent>{children}</ModalContent>
<ModalFooter
confirmText={confirmText}

View File

@@ -1,39 +1,42 @@
import React from 'react';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import { useDictionary } from 'providers/Dictionary/index';
const Support = () => {
const { dictionary } = useDictionary();
return (
<StyledWrapper>
<div className="rows">
<div className="mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
<IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span>
<span className="label ml-2">{dictionary.documentation}</span>
</a>
</div>
<div className="mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="flex items-end">
<IconSpeakerphone size={18} strokeWidth={2} />
<span className="label ml-2">Report Issues</span>
<span className="label ml-2">{dictionary.reportIssues}</span>
</a>
</div>
<div className="mt-2">
<a href="https://discord.com/invite/KgcZUncpjq" target="_blank" className="flex items-end">
<IconBrandDiscord size={18} strokeWidth={2} />
<span className="label ml-2">Discord</span>
<span className="label ml-2">{dictionary.discord}</span>
</a>
</div>
<div className="mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-end">
<IconBrandGithub size={18} strokeWidth={2} />
<span className="label ml-2">GitHub</span>
<span className="label ml-2">{dictionary.gitHub}</span>
</a>
</div>
<div className="mt-2">
<a href="https://twitter.com/use_bruno" target="_blank" className="flex items-end">
<IconBrandTwitter size={18} strokeWidth={2} />
<span className="label ml-2">Twitter</span>
<span className="label ml-2">{dictionary.twitter}</span>
</a>
</div>
</div>

View File

@@ -1,7 +1,4 @@
import React from 'react';
import { useTheme } from 'providers/Theme/index';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
/**
* Assertion operators
@@ -81,16 +78,10 @@ const AssertionOperator = ({ operator, onChange }) => {
}
};
const { storedTheme } = useTheme();
return (
<select value={operator} onChange={handleChange} className="mousetrap">
{operators.map((operator) => (
<option
style={{ backgroundColor: storedTheme === 'dark' ? darkTheme.bg : lightTheme.bg }}
key={operator}
value={operator}
>
<option key={operator} value={operator}>
{getLabel(operator)}
</option>
))}

View File

@@ -55,6 +55,9 @@ const Wrapper = styled.div`
position: relative;
top: 1px;
}
option {
background-color: ${(props) => props.theme.bg};
}
`;
export default Wrapper;

View File

@@ -23,6 +23,10 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
.content-indicator {
color: ${(props) => props.theme.text}
}
}
}
`;

View File

@@ -7,7 +7,7 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
import AuthMode from 'components/RequestPane/Auth/AuthMode';
import DotIcon from 'components/Icons/Dot';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@@ -16,6 +16,12 @@ import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
const ContentIndicator = () => {
return <sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -82,12 +88,17 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
// get the length of active params, headers, asserts and vars
const params = item.draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []);
const headers = item.draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []);
const assertions = item.draft ? get(item, 'draft.request.assertions', []) : get(item, 'request.assertions', []);
const requestVars = item.draft ? get(item, 'draft.request.vars.req', []) : get(item, 'request.vars.req', []);
const responseVars = item.draft ? get(item, 'draft.request.vars.res', []) : get(item, 'request.vars.res', []);
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
const getPropertyFromDraftOrRequest = (propertyKey) =>
item.draft ? get(item, `draft.${propertyKey}`, []) : get(item, propertyKey, []);
const params = getPropertyFromDraftOrRequest('request.params');
const body = getPropertyFromDraftOrRequest('request.body');
const headers = getPropertyFromDraftOrRequest('request.headers');
const script = getPropertyFromDraftOrRequest('request.script');
const assertions = getPropertyFromDraftOrRequest('request.assertions');
const tests = getPropertyFromDraftOrRequest('request.tests');
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@@ -105,10 +116,11 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body
{body.mode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{activeHeadersLength > 0 && <sup className="ml-1 font-medium">{activeHeadersLength}</sup>}
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
@@ -119,6 +131,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => selectTab('script')}>
Script
{(script.req || script.res) && <ContentIndicator />}
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
Assert
@@ -126,6 +139,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
{tests && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs

View File

@@ -1,7 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import has from 'lodash/has';
import Tooltip from 'components/Tooltip';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -103,7 +103,7 @@ const QueryParams = ({ item, collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<div className="mb-2 title text-xs">Query</div>
<table>
<thead>
<tr>
@@ -173,7 +173,22 @@ const QueryParams = ({ item, collection }) => {
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="mb-1 title text-xs">Path</div>
<div className="mb-2 title text-xs flex items-stretch">
<span>Path</span>
<Tooltip
text={`
<div>
Path variables are automatically added whenever the
<code className="font-mono mx-2">:name</code>
template is used in the URL. <br/> For example:
<code className="font-mono mx-2">
https://example.com/v1/users/<span>:id</span>
</code>
</div>
`}
tooltipId="path-param-tooltip"
/>
</div>
<table>
<thead>
<tr>
@@ -224,6 +239,11 @@ const QueryParams = ({ item, collection }) => {
: null}
</tbody>
</table>
{!(pathParams && pathParams.length) ?
<div className="title pr-2 py-3 mt-2 text-xs">
</div>
: null}
</div>
</StyledWrapper>
);

View File

@@ -30,7 +30,7 @@ const RequestNotFound = ({ itemUid }) => {
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700 bg-yellow-100 p-4">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Request no longer exists.</div>
<div className="mt-2">
This can happen when the .bru file associated with this request was deleted on your filesystem.

View File

@@ -18,6 +18,7 @@ import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
const MIN_LEFT_PANE_WIDTH = 300;
@@ -137,6 +138,10 @@ const RequestTabPanel = () => {
return <FolderSettings collection={collection} folder={folder} />;
}
if (focusedTab.type === 'security-settings') {
return <SecuritySettings collection={collection} />;
}
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;

View File

@@ -5,6 +5,7 @@ import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
@@ -47,6 +48,9 @@ const CollectionToolBar = ({ collection }) => {
<span className="ml-2 mr-4 font-semibold">{collection?.name}</span>
</div>
<div className="flex flex-1 items-center justify-end">
<span className="mr-2">
<JsSandboxMode collection={collection} />
</span>
<span className="mr-2">
<IconRun className="cursor-pointer" size={20} strokeWidth={1.5} onClick={handleRun} />
</span>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IconVariable, IconSettings, IconRun, IconFolder } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName }) => {
const getTabInfo = (type, tabName) => {
@@ -12,6 +12,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
</>
);
}
case 'security-settings': {
return (
<>
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1">Security</span>
</>
)
}
case 'folder-settings': {
return (
<div className="flex items-center flex-nowrap overflow-hidden">

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -12,12 +12,18 @@ import ConfirmRequestClose from './ConfirmRequestClose';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
import NewRequest from 'components/Sidebar/NewRequest/index';
const RequestTab = ({ tab, collection, folderUid }) => {
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -28,6 +34,19 @@ const RequestTab = ({ tab, collection, folderUid }) => {
);
};
const handleRightClick = (_event) => {
const menuDropdown = dropdownTippyRef.current;
if (!menuDropdown) {
return;
}
if (menuDropdown.state.isShown) {
menuDropdown.hide();
} else {
menuDropdown.show();
}
};
const handleMouseUp = (e) => {
if (e.button === 1) {
e.stopPropagation();
@@ -43,45 +62,11 @@ const RequestTab = ({ tab, collection, folderUid }) => {
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
let color = '';
method = method.toLocaleLowerCase();
switch (method) {
case 'get': {
color = theme.request.methods.get;
break;
}
case 'post': {
color = theme.request.methods.post;
break;
}
case 'put': {
color = theme.request.methods.put;
break;
}
case 'delete': {
color = theme.request.methods.delete;
break;
}
case 'patch': {
color = theme.request.methods.patch;
break;
}
case 'options': {
color = theme.request.methods.options;
break;
}
case 'head': {
color = theme.request.methods.head;
break;
}
}
return color;
return theme.request.methods[method.toLocaleLowerCase()];
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner'].includes(tab.type)) {
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{tab.type === 'folder-settings' ? (
@@ -143,6 +128,7 @@ const RequestTab = ({ tab, collection, folderUid }) => {
)}
<div
className="flex items-baseline tab-label pl-2"
onContextMenu={handleRightClick}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);
@@ -159,6 +145,15 @@ const RequestTab = ({ tab, collection, folderUid }) => {
<span className="ml-1 tab-name" title={item.name}>
{item.name}
</span>
<RequestTabMenu
onDropdownCreate={onDropdownCreate}
tabIndex={tabIndex}
collectionRequestTabs={collectionRequestTabs}
tabItem={item}
collection={collection}
dropdownTippyRef={dropdownTippyRef}
dispatch={dispatch}
/>
</div>
<div
className="flex px-2 close-icon-container"
@@ -195,4 +190,124 @@ const RequestTab = ({ tab, collection, folderUid }) => {
);
};
function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, collection, dropdownTippyRef, dispatch }) {
const [showCloneRequestModal, setShowCloneRequestModal] = useState(false);
const [showAddNewRequestModal, setShowAddNewRequestModal] = useState(false);
const totalTabs = collectionRequestTabs.length || 0;
const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
const currentTabItem = findItemInCollection(collection, currentTabUid);
const hasLeftTabs = tabIndex !== 0;
const hasRightTabs = totalTabs > tabIndex + 1;
const hasOtherTabs = totalTabs > 1;
async function handleCloseTab(event, tabUid) {
event.stopPropagation();
dropdownTippyRef.current.hide();
if (!tabUid) {
return;
}
try {
const item = findItemInCollection(collection, tabUid);
// silently save unsaved changes before closing the tab
if (item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
}
dispatch(closeTabs({ tabUids: [tabUid] }));
} catch (err) {}
}
function handleCloseOtherTabs(event) {
dropdownTippyRef.current.hide();
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
otherTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseTabsToTheLeft(event) {
dropdownTippyRef.current.hide();
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
leftTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseTabsToTheRight(event) {
dropdownTippyRef.current.hide();
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
rightTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseSavedTabs(event) {
event.stopPropagation();
const savedTabs = collection.items.filter((item) => !item.draft);
const savedTabIds = savedTabs.map((item) => item.uid) || [];
dispatch(closeTabs({ tabUids: savedTabIds }));
}
function handleCloseAllTabs(event) {
collectionRequestTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
onClose={() => setShowCloneRequestModal(false)}
/>
)}
<Dropdown onCreate={onDropdownCreate} icon={<span></span>} placement="bottom-start">
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowAddNewRequestModal(true);
}}
>
New Request
</button>
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowCloneRequestModal(true);
}}
>
Clone Request
</button>
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
Close
</button>
<button disabled={!hasOtherTabs} className="dropdown-item w-full" onClick={handleCloseOtherTabs}>
Close Others
</button>
<button disabled={!hasLeftTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheLeft}>
Close to the Left
</button>
<button disabled={!hasRightTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheRight}>
Close to the Right
</button>
<button className="dropdown-item w-full" onClick={handleCloseSavedTabs}>
Close Saved
</button>
<button className="dropdown-item w-full" onClick={handleCloseAllTabs}>
Close All
</button>
</Dropdown>
</Fragment>
);
}
export default RequestTab;

View File

@@ -7,7 +7,6 @@ const Wrapper = styled.div`
padding: 0;
margin: 0;
display: flex;
position: relative;
overflow: scroll;
&::-webkit-scrollbar {

View File

@@ -110,7 +110,14 @@ const RequestTabs = () => {
role="tab"
onClick={() => handleClick(tab)}
>
<RequestTab key={tab.uid} tab={tab} collection={activeCollection} folderUid={tab.folderUid} />
<RequestTab
collectionRequestTabs={collectionRequestTabs}
tabIndex={index}
key={tab.uid}
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
/>
</li>
);
})

View File

@@ -3,6 +3,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
position: absolute;
height: 100%;
width: calc(100% - 0.75rem);
z-index: 1;
background-color: ${(props) => props.theme.requestTabPanel.responseOverlayBg};

View File

@@ -13,7 +13,7 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
};
return (
<StyledWrapper className="px-3 w-full">
<StyledWrapper className="w-full">
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>

View File

@@ -46,7 +46,7 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
return (
<div
className={
'response-filter absolute bottom-2 w-full justify-end right-0 flex flex-row items-center gap-2 py-4 px-2'
'response-filter absolute bottom-2 w-full justify-end right-0 flex flex-row items-center gap-2 py-4 px-2 pointer-events-none'
}
>
{tooltipText && !isExpanded && <ReactTooltip anchorId={'request-filter-icon'} html={tooltipText} />}
@@ -61,11 +61,11 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
autoCapitalize="off"
spellCheck="false"
className={`block ml-14 p-2 py-1 sm:text-sm transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${
isExpanded ? 'w-full opacity-100' : 'w-[0] opacity-0'
isExpanded ? 'w-full opacity-100 pointer-events-auto' : 'w-[0] opacity-0'
}`}
onChange={onChange}
/>
<div className="text-gray-500 sm:text-sm cursor-pointer" id="request-filter-icon" onClick={handleFilterClick}>
<div className="text-gray-500 sm:text-sm cursor-pointer pointer-events-auto" id="request-filter-icon" onClick={handleFilterClick}>
{isExpanded ? <IconX size={20} strokeWidth={1.5} /> : <IconFilter size={20} strokeWidth={1.5} />}
</div>
</div>

View File

@@ -97,6 +97,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
@@ -105,7 +107,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{response.headers?.length > 0 && <sup className="ml-1 font-medium">{response.headers.length}</sup>}
{responseHeadersCount > 0 && <sup className="ml-1 font-medium">{responseHeadersCount}</sup>}
</div>
<div className={getTabClassname('timeline')} role="tab" onClick={() => selectTab('timeline')}>
Timeline

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.safe-mode {
padding: 0.15rem 0.3rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
.developer-mode {
padding: 0.15rem 0.3rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,45 @@
import { useDispatch } from 'react-redux';
import { IconShieldLock } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common/index';
import JsSandboxModeModal from '../JsSandboxModeModal';
import StyledWrapper from './StyledWrapper';
const JsSandboxMode = ({ collection }) => {
const jsSandboxMode = collection?.securityConfig?.jsSandboxMode;
const dispatch = useDispatch();
const viewSecuritySettings = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'security-settings'
})
);
};
return (
<StyledWrapper className='flex'>
{jsSandboxMode === 'safe' && (
<div
className="flex items-center border rounded-md text-xs cursor-pointer safe-mode"
onClick={viewSecuritySettings}
>
Safe Mode
</div>
)}
{jsSandboxMode === 'developer' && (
<div
className="flex items-center border rounded-md text-xs cursor-pointer developer-mode"
onClick={viewSecuritySettings}
>
Developer Mode
</div>
)}
{!jsSandboxMode ? <JsSandboxModeModal collection={collection} /> : null}
</StyledWrapper>
);
};
export default JsSandboxMode;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
span.beta-tag {
display: flex;
align-items: center;
padding: 0.1rem 0.25rem;
font-size: 0.75rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,98 @@
import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
const JsSandboxModeModal = ({ collection }) => {
const dispatch = useDispatch();
const [jsSandboxMode, setJsSandboxMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
const handleChange = (e) => {
setJsSandboxMode(e.target.value);
};
const handleSave = () => {
dispatch(
saveCollectionSecurityConfig(collection?.uid, {
jsSandboxMode: jsSandboxMode
})
)
.then(() => {
toast.success('Sandbox mode updated successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to update sandbox mode'));
};
return (
<Portal>
<Modal
size="sm"
title={'JavaScript Sandbox'}
confirmText="Save"
handleConfirm={handleSave}
hideCancel={true}
hideClose={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<StyledWrapper>
<div>
The collection might include JavaScript code in Variables, Scripts, Tests, and Assertions.
</div>
<div className='text-muted mt-6'>
Please choose the security level for the JavaScript code execution.
</div>
<div className="flex flex-col mt-4">
<label htmlFor="safe" className="flex flex-row items-center gap-2 cursor-pointer">
<input
type="radio"
id="safe"
name="jsSandboxMode"
value="safe"
checked={jsSandboxMode === 'safe'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'safe' ? 'font-medium' : 'font-normal'}>
Safe Mode
</span>
<span className='beta-tag'>BETA</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
</p>
<label htmlFor="developer" className="flex flex-row gap-2 mt-6 cursor-pointer">
<input
type="radio"
id="developer"
name="jsSandboxMode"
value="developer"
checked={jsSandboxMode === 'developer'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'developer' ? 'font-medium' : 'font-normal'}>
Developer Mode
<span className='ml-1 developer-mode-warning'>(use only if you trust the collections authors)</span>
</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
</p>
<small className='text-muted mt-6'>
* SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
</small>
</div>
</StyledWrapper>
</Modal>
</Portal>
);
};
export default JsSandboxModeModal;

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
span.beta-tag {
display: flex;
align-items: center;
padding: 0.1rem 0.25rem;
font-size: 0.75rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.colors.text.green};
border: solid 1px ${(props) => props.theme.colors.text.green} !important;
}
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { useDispatch } from 'react-redux';
const SecuritySettings = ({ collection }) => {
const dispatch = useDispatch();
const [jsSandboxMode, setJsSandboxMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
const handleChange = (e) => {
setJsSandboxMode(e.target.value);
};
const handleSave = () => {
dispatch(
saveCollectionSecurityConfig(collection?.uid, {
jsSandboxMode: jsSandboxMode
})
)
.then(() => {
toast.success('Sandbox mode updated successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to update sandbox mode'));
};
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<div className='font-semibold mt-2'>JavaScript Sandbox</div>
<div className='mt-4'>
The collection might include JavaScript code in Variables, Scripts, Tests, and Assertions.
</div>
<div className="flex flex-col mt-4">
<div className="flex flex-col">
<label htmlFor="safe" className="flex flex-row items-center gap-2 cursor-pointer">
<input
type="radio"
id="safe"
name="jsSandboxMode"
value="safe"
checked={jsSandboxMode === 'safe'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'safe' ? 'font-medium' : 'font-normal'}>
Safe Mode
</span>
<span className='beta-tag'>BETA</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
</p>
<label htmlFor="developer" className="flex flex-row gap-2 mt-6 cursor-pointer">
<input
type="radio"
id="developer"
name="jsSandboxMode"
value="developer"
checked={jsSandboxMode === 'developer'}
onChange={handleChange}
className="cursor-pointer"
/>
<span className={jsSandboxMode === 'developer' ? 'font-medium' : 'font-normal'}>
Developer Mode
<span className='ml-1 developer-mode-warning'>(use only if you trust the collections authors)</span>
</span>
</label>
<p className='text-sm text-muted mt-1'>
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
</p>
</div>
<button onClick={handleSave} className="submit btn btn-sm btn-secondary w-fit mt-6">
Save
</button>
<small className='text-muted mt-6'>
* SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
</small>
</div>
</StyledWrapper>
);
};
export default SecuritySettings;

View File

@@ -23,7 +23,9 @@ const RequestMethod = ({ item }) => {
return (
<StyledWrapper>
<div className={getClassname(item.request.method)}>
<span className="uppercase">{item.request.method}</span>
<span className="uppercase">
{item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
</span>
</div>
</StyledWrapper>
);

View File

@@ -115,7 +115,7 @@ const Collections = () => {
)}
</div>
<div className="mt-4 flex flex-col overflow-y-auto absolute top-32 bottom-10 left-0 right-0">
<div className="mt-4 flex flex-col overflow-hidden hover:overflow-y-auto absolute top-32 bottom-10 left-0 right-0">
{collections && collections.length
? collections.map((c) => {
return (

View File

@@ -115,7 +115,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
collectionLocation: Yup.string()
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
.required('name is required')
.required('Location is required')
}),
onSubmit: (values) => {
handleSubmit(values.collectionLocation);
@@ -124,7 +124,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
formik.setFieldValue('collectionLocation', dirPath);
if (typeof dirPath === 'string' && dirPath.length > 0) {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');

View File

@@ -109,7 +109,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
collectionUid: collection.uid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body
body: request.body,
auth: request.auth
})
)
.then(() => onClose())

View File

@@ -129,7 +129,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.25.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.26.2</div>
</div>
</div>
</div>

View File

@@ -13,7 +13,6 @@ const Tooltip = ({ text, tooltipId }) => {
fill="currentColor"
className="inline-block ml-2 cursor-pointer"
viewBox="0 0 16 16"
style={{ marginTop: 1 }}
>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z" />

View File

@@ -9,9 +9,11 @@ import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import StyledWrapper from './StyledWrapper';
import { useDictionary } from 'providers/Dictionary/index';
const Welcome = () => {
const dispatch = useDispatch();
const { dictionary } = useDictionary();
const [importedCollection, setImportedCollection] = useState(null);
const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
@@ -20,7 +22,7 @@ const Welcome = () => {
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
(err) => console.log(err) && toast.error(dictionary.errorWhileOpeningCollection)
);
};
@@ -38,12 +40,12 @@ const Welcome = () => {
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportedCollection(null);
toast.success('Collection imported successfully');
toast.success(dictionary.collectionImportedSuccessfully);
})
.catch((err) => {
setImportCollectionLocationModalOpen(false);
console.error(err);
toast.error('An error occurred while importing the collection. Check the logs for more information.');
toast.error(dictionary.errorWhileImportingCollection);
});
};
@@ -66,46 +68,45 @@ const Welcome = () => {
<Bruno width={50} />
</div>
<div className="text-xl font-semibold select-none">bruno</div>
<div className="mt-4">Opensource IDE for exploring and testing APIs</div>
<div className="mt-4">{dictionary.aboutBruno}</div>
<div className="uppercase font-semibold heading mt-10">Collections</div>
<div className="uppercase font-semibold heading mt-10">{dictionary.collections}</div>
<div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}>
<IconPlus size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection">
Create Collection
{dictionary.createCollection}
</span>
</div>
<div className="flex items-center ml-6" onClick={handleOpenCollection}>
<IconFolders size={18} strokeWidth={2} />
<span className="label ml-2">Open Collection</span>
<span className="label ml-2">{dictionary.openCollection}</span>
</div>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconDownload size={18} strokeWidth={2} />
<span className="label ml-2" id="import-collection">
Import Collection
{dictionary.importCollection}
</span>
</div>
</div>
<div className="uppercase font-semibold heading mt-10 pt-6">Links</div>
<div className="mt-4 flex flex-col collection-options select-none">
<div className="flex items-center mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center">
<IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span>
<span className="label ml-2">{dictionary.documentation}</span>
</a>
</div>
<div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center">
<IconSpeakerphone size={18} strokeWidth={2} />
<span className="label ml-2">Report Issues</span>
<span className="label ml-2">{dictionary.reportIssues}</span>
</a>
</div>
<div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center">
<IconBrandGithub size={18} strokeWidth={2} />
<span className="label ml-2">GitHub</span>
<span className="label ml-2">{dictionary.gitHub}</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,16 @@
export default {
aboutBruno: 'Opensource IDE for exploring and testing APIs',
collections: 'Collections',
createCollection: 'Create Collection',
openCollection: 'Open Collection',
importCollection: 'Import Collection',
documentation: 'Documentation',
reportIssues: 'Report Issues',
gitHub: 'GitHub',
collectionImportedSuccessfully: 'Collection imported successfully',
errorWhileOpeningCollection: 'An error occurred while opening the collection',
errorWhileImportingCollection:
'An error occurred while importing the collection. Check the logs for more information.',
discord: 'Discord',
twitter: 'Twitter'
};

View File

@@ -0,0 +1,5 @@
import en from './en.js';
export const dictionaries = {
en
};

View File

@@ -14,6 +14,16 @@ import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
import 'react-tooltip/dist/react-tooltip.css';
import '@usebruno/graphql-docs/dist/esm/index.css';
import '@fontsource/inter/100.css';
import '@fontsource/inter/200.css';
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import '@fontsource/inter/900.css';
import { DictionaryProvider } from 'providers/Dictionary/index';
function SafeHydrate({ children }) {
return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>;
@@ -59,13 +69,15 @@ function MyApp({ Component, pageProps }) {
<NoSsr>
<Provider store={ReduxStore}>
<ThemeProvider>
<ToastProvider>
<AppProvider>
<HotkeysProvider>
<Component {...pageProps} />
</HotkeysProvider>
</AppProvider>
</ToastProvider>
<DictionaryProvider>
<ToastProvider>
<AppProvider>
<HotkeysProvider>
<Component {...pageProps} />
</HotkeysProvider>
</AppProvider>
</ToastProvider>
</DictionaryProvider>
</ThemeProvider>
</Provider>
</NoSsr>

View File

@@ -30,12 +30,7 @@ export default class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</Head>
<Head />
<body id="bruno-app-body">
<Main />
<NextScript />

View File

@@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
version: '1.25.0'
version: '1.26.2'
}
});
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useState, useContext } from 'react';
import { dictionaries } from 'src/dictionaries/index';
export const DictionaryContext = React.createContext();
const DictionaryProvider = (props) => {
const [language, setLanguage] = useState('en');
const dictionary = dictionaries[language] ?? dictionaries.en;
return (
<DictionaryContext.Provider {...props} value={{ language, setLanguage, dictionary }}>
<>{props.children}</>
</DictionaryContext.Provider>
);
};
const useDictionary = () => {
const context = useContext(DictionaryContext);
if (context === undefined) {
throw new Error(`useDictionary must be used within a DictionaryProvider`);
}
return context;
};
export { useDictionary, DictionaryProvider };

View File

@@ -154,6 +154,31 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid]);
// close all tabs
useEffect(() => {
Mousetrap.bind(['command+shift+w', 'ctrl+shift+w'], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
dispatch(
closeTabs({
tabUids: tabUids
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+shift+w', 'ctrl+shift+w']);
};
}, [activeTabUid, tabs, collections, dispatch]);
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showSaveRequestModal && (

View File

@@ -33,13 +33,14 @@ import {
requestCancelled,
resetRunResults,
responseReceived,
updateLastAction
updateLastAction,
setCollectionSecurityConfig
} from './index';
import { each } from 'lodash';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parseQueryParams, splitOnFirst } from 'utils/url/index';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader';
@@ -373,6 +374,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
});
};
// rename item
export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -698,7 +700,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
};
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body } = params;
const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -708,11 +710,20 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
}
const parts = splitOnFirst(requestUrl, '?');
const params = parseQueryParams(parts[1]);
each(params, (urlParam) => {
const queryParams = parseQueryParams(parts[1]);
each(queryParams, (urlParam) => {
urlParam.enabled = true;
urlParam.type = 'query';
});
const pathParams = parsePathParams(requestUrl);
each(pathParams, (pathParm) => {
pathParams.enabled = true;
pathParm.type = 'path';
});
const params = [...queryParams, ...pathParams];
const item = {
uid: uuid(),
type: requestType,
@@ -730,6 +741,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
sparql: null,
multipartForm: null,
formUrlEncoded: null
},
auth: auth ?? {
mode: 'none'
}
}
};
@@ -1039,11 +1053,13 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
};
return new Promise((resolve, reject) => {
collectionSchema
.validate(collection)
.then(() => dispatch(_createCollection(collection)))
.then(resolve)
.catch(reject);
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
collectionSchema
.validate(collection)
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
.then(resolve)
.catch(reject);
});
});
};
@@ -1108,3 +1124,19 @@ export const importCollection = (collection, collectionLocation) => (dispatch, g
ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation).then(resolve).catch(reject);
});
};
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
ipcRenderer
.invoke('renderer:save-collection-security-config', collection?.pathname, securityConfig)
.then(async () => {
await dispatch(setCollectionSecurityConfig({ collectionUid, securityConfig }));
resolve();
})
.catch(reject);
});
};

View File

@@ -33,7 +33,6 @@ export const collectionsSlice = createSlice({
const collection = action.payload;
collection.settingsSelectedTab = 'headers';
collection.folderLevelSettingsSelectedTab = {};
// TODO: move this to use the nextAction approach
@@ -51,6 +50,12 @@ export const collectionsSlice = createSlice({
state.collections.push(collection);
}
},
setCollectionSecurityConfig: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.securityConfig = action.payload.securityConfig;
}
},
brunoConfigUpdateEvent: (state, action) => {
const { collectionUid, brunoConfig } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -1622,6 +1627,7 @@ export const collectionsSlice = createSlice({
export const {
createCollection,
setCollectionSecurityConfig,
brunoConfigUpdateEvent,
renameCollection,
removeCollection,

View File

@@ -24,7 +24,9 @@ export const tabsSlice = createSlice({
return;
}
if (['variables', 'collection-settings', 'collection-runner'].includes(action.payload.type)) {
if (
['variables', 'collection-settings', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {
state.activeTabUid = tab.uid;

View File

@@ -50,7 +50,11 @@ const createPostData = (body) => {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({ name: param.name, value: param.value }))
.map((param) => ({
name: param.name,
value: param.value,
...(param.type === 'file' && { fileName: param.value })
}))
};
} else {
return {

View File

@@ -3,7 +3,7 @@ import filter from 'lodash/filter';
import find from 'lodash/find';
export const doesRequestMatchSearchText = (request, searchText = '') => {
return request.name.toLowerCase().includes(searchText.toLowerCase());
return request?.name?.toLowerCase().includes(searchText.toLowerCase());
};
export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => {

View File

@@ -123,7 +123,8 @@ const curlToJson = (curlCommand) => {
request.urlWithoutQuery = 'http://' + request.urlWithoutQuery;
}
requestJson.url = request.urlWithoutQuery
requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url;
requestJson.method = request.method;
if (request.cookies) {
@@ -159,14 +160,15 @@ const curlToJson = (curlCommand) => {
}
if (request.auth) {
const splitAuth = request.auth.split(':');
const user = splitAuth[0] || '';
const password = splitAuth[1] || '';
requestJson.auth = {
user: repr(user),
password: repr(password)
};
if(request.auth.mode === 'basic'){
requestJson.auth = {
mode: 'basic',
basic: {
username: repr(request.auth.basic?.username),
password: repr(request.auth.basic?.password)
}
}
}
}
return Object.keys(requestJson).length ? requestJson : {};

View File

@@ -75,4 +75,15 @@ describe('curlToJson', () => {
}
});
});
it('should return and parse a simple curl command with a trailing slash', () => {
const curlCommand = 'curl https://www.usebruno.com/';
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com/',
raw_url: 'https://www.usebruno.com/',
method: 'get'
});
});
});

View File

@@ -56,7 +56,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
url: request.url,
method: request.method,
body,
headers: headers
headers: headers,
auth: request.auth
};
} catch (error) {
console.error(error);

View File

@@ -36,7 +36,8 @@ const parseCurlCommand = (curlCommand) => {
boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'],
alias: {
H: 'header',
A: 'user-agent'
A: 'user-agent',
u: 'user'
}
});
@@ -187,10 +188,21 @@ const parseCurlCommand = (curlCommand) => {
}
urlObject.search = null; // Clean out the search/query portion.
let urlWithoutQuery = URL.format(urlObject);
let urlHost = urlObject?.host;
if (!url?.includes(`${urlHost}/`)) {
if (urlWithoutQuery && urlHost) {
const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost);
urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1);
}
}
const request = {
url: url,
urlWithoutQuery: URL.format(urlObject)
url,
urlWithoutQuery
};
if (compressed) {
request.compressed = true;
}
@@ -226,12 +238,19 @@ const parseCurlCommand = (curlCommand) => {
request.data = parsedArguments['data-urlencode'];
}
if (parsedArguments.u) {
request.auth = parsedArguments.u;
}
if (parsedArguments.user) {
request.auth = parsedArguments.user;
if (parsedArguments.user && typeof parsedArguments.user === 'string') {
const basicAuth = parsedArguments.user.split(':')
const username = basicAuth[0] || ''
const password = basicAuth[1] || ''
request.auth = {
mode: 'basic',
basic: {
username,
password
}
}
}
if (Array.isArray(request.data)) {
request.dataArray = request.data;
request.data = request.data.join('&');

View File

@@ -12,6 +12,7 @@ export const exportCollection = (collection) => {
const generateInfoSection = () => {
return {
name: collection.name,
description: collection.root?.docs,
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
};
};
@@ -137,6 +138,11 @@ export const exportCollection = (collection) => {
}
}
};
case 'graphql':
return {
mode: 'graphql',
graphql: body.graphql
};
}
};
@@ -201,6 +207,8 @@ export const exportCollection = (collection) => {
const requestObject = {
method: itemRequest.method,
header: generateHeaders(itemRequest.headers),
auth: generateAuth(itemRequest.auth),
description: itemRequest.docs,
url: {
raw: itemRequest.url,
host: generateHost(itemRequest.url),

View File

@@ -59,12 +59,15 @@ const transformOpenapiRequestItem = (request) => {
operationName = `${request.method} ${request.path}`;
}
// replace OpenAPI links in path by Bruno variables
let path = request.path.replace(/{([a-zA-Z]+)}/g, `{{${_operationObject.operationId}_$1}}`);
const brunoRequestItem = {
uid: uuid(),
name: operationName,
type: 'http-request',
request: {
url: ensureUrl(request.global.server + '/' + request.path),
url: ensureUrl(request.global.server + '/' + path),
method: request.method.toUpperCase(),
auth: {
mode: 'none',
@@ -81,6 +84,9 @@ const transformOpenapiRequestItem = (request) => {
xml: null,
formUrlEncoded: [],
multipartForm: []
},
script: {
res: null
}
}
};
@@ -195,6 +201,26 @@ const transformOpenapiRequestItem = (request) => {
}
}
// build the extraction scripts from responses that have links
// https://swagger.io/docs/specification/links/
let script = [];
each(_operationObject.responses || [], (response, responseStatus) => {
if (Object.hasOwn(response, 'links')) {
// only extract if the status code matches the response
script.push(`if (res.status === ${responseStatus}) {`);
each(response.links, (link) => {
each(link.parameters || [], (expression, parameter) => {
let value = openAPIRuntimeExpressionToScript(expression);
script.push(` bru.setVar('${link.operationId}_${parameter}', ${value});`);
});
});
script.push(`}`);
}
});
if (script.length > 0) {
brunoRequestItem.request.script.res = script.join('\n');
}
return brunoRequestItem;
};
@@ -305,6 +331,18 @@ const getSecurity = (apiSpec) => {
};
};
const openAPIRuntimeExpressionToScript = (expression) => {
// see https://swagger.io/docs/specification/links/#runtime-expressions
if (expression === '$response.body') {
return 'res.body';
} else if (expression.startsWith('$response.body#')) {
let pointer = expression.substring(15);
// could use https://www.npmjs.com/package/json-pointer for better support
return `res.body${pointer.replace('/', '.')}`;
}
return expression;
};
const parseOpenApiCollection = (data) => {
const brunoCollection = {
name: '',
@@ -348,7 +386,7 @@ const parseOpenApiCollection = (data) => {
.map(([method, operationObject]) => {
return {
method: method,
path: path,
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
operationObject: operationObject,
global: {
server: baseUrl,

View File

@@ -113,7 +113,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
xml: null,
formUrlEncoded: [],
multipartForm: []
}
},
docs: i.request.description
}
};
/* struct of translation log

View File

@@ -14,7 +14,7 @@
"url": "git+https://github.com/usebruno/bruno.git"
},
"scripts": {
"test": "jest"
"test": "node --experimental-vm-modules $(npx which jest)"
},
"files": [
"src",

View File

@@ -190,6 +190,10 @@ const getFolderRoot = (dir) => {
return collectionBruToJson(content);
};
const getJsSandboxRuntime = (sandbox) => {
return sandbox === 'safe' ? 'quickjs' : 'vm2';
};
const builder = async (yargs) => {
yargs
.option('r', {
@@ -215,6 +219,11 @@ const builder = async (yargs) => {
describe: 'Overwrite a single environment variable, multiple usages possible',
type: 'string'
})
.option('sandbox', {
describe: 'Javscript sandbox to use; available sandboxes are "developer" (default) or "safe"',
default: 'developer',
type: 'string'
})
.option('output', {
alias: 'o',
describe: 'Path to write file results to',
@@ -232,7 +241,7 @@ const builder = async (yargs) => {
})
.option('tests-only', {
type: 'boolean',
description: 'Only run requests that have a test'
description: 'Only run requests that have a test or active assertion'
})
.option('bail', {
type: 'boolean',
@@ -282,6 +291,7 @@ const handler = async function (argv) {
r: recursive,
output: outputPath,
format,
sandbox,
testsOnly,
bail
} = argv;
@@ -451,6 +461,7 @@ const handler = async function (argv) {
}
}
const runtime = getJsSandboxRuntime(sandbox);
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
@@ -466,7 +477,8 @@ const handler = async function (argv) {
envVars,
processEnvVars,
brunoConfig,
collectionRoot
collectionRoot,
runtime
);
results.push({

View File

@@ -21,6 +21,10 @@ const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util'
const path = require('path');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => {
console[type](...args);
};
const runSingleRequest = async function (
filename,
bruJson,
@@ -29,7 +33,8 @@ const runSingleRequest = async function (
envVariables,
processEnvVars,
brunoConfig,
collectionRoot
collectionRoot,
runtime
) {
try {
let request;
@@ -38,6 +43,7 @@ const runSingleRequest = async function (
request = prepareRequest(bruJson.request, collectionRoot);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime;
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
@@ -57,7 +63,7 @@ const runSingleRequest = async function (
// run pre-request vars
const preRequestVars = get(bruJson, 'request.vars.req');
if (preRequestVars?.length) {
const varsRuntime = new VarsRuntime();
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPreRequestVars(
preRequestVars,
request,
@@ -74,14 +80,14 @@ const runSingleRequest = async function (
get(bruJson, 'request.script.req')
]).join(os.EOL);
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime();
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
decomment(requestScriptFile),
request,
envVariables,
runtimeVariables,
collectionPath,
null,
onConsoleLog,
processEnvVars,
scriptingConfig
);
@@ -276,7 +282,7 @@ const runSingleRequest = async function (
// run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res');
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime();
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
postResponseVars,
request,
@@ -294,7 +300,7 @@ const runSingleRequest = async function (
get(bruJson, 'request.script.res')
]).join(os.EOL);
if (responseScriptFile?.length) {
const scriptRuntime = new ScriptRuntime();
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runResponseScript(
decomment(responseScriptFile),
request,
@@ -315,7 +321,7 @@ const runSingleRequest = async function (
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
assertionResults = assertRuntime.runAssertions(
assertions,
request,
@@ -339,7 +345,7 @@ const runSingleRequest = async function (
let testResults = [];
const testFile = compact([get(collectionRoot, 'request.tests'), get(bruJson, 'request.tests')]).join(os.EOL);
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const result = await testRuntime.runTests(
decomment(testFile),
request,

View File

@@ -22,13 +22,13 @@
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup": "3.2.5",
"rollup":"3.29.4",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup": "3.2.5"
"rollup":"3.29.4"
}
}

View File

@@ -294,7 +294,7 @@ describe('interpolate - recursive', () => {
const result = interpolate(inputString, inputObject);
expect(result).toBe('{{recursion2}}');
expect(result).toBe('{{recursion3}}');
});
it('should replace repetead placeholders with 1 level of recursion with values from the object', () => {
@@ -335,4 +335,22 @@ describe('interpolate - recursive', () => {
expect(result).toBe(new Array(24).fill('repetead4').join(' '));
});
it('should replace mutiple interdependent variables in the same input string', () => {
const inputString = `{
"x": "{{v2}} {{v1}}"
}`;
const inputObject = {
foo: 'bar',
v1: '{{foo}}',
v2: '{{bar}}',
bar: 'baz'
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`{
"x": "baz bar"
}`);
});
});

View File

@@ -27,32 +27,41 @@ const interpolate = (str: string, obj: Record<string, any>): string => {
const replace = (
str: string,
flattenedObj: Record<string, any>,
visited = new Set<String>(),
visited = new Set<string>(),
results = new Map<string, string>()
): string => {
const patternRegex = /\{\{([^}]+)\}\}/g;
let resultStr = str;
let matchFound = true;
return str.replace(patternRegex, (match, placeholder) => {
const replacement = flattenedObj[placeholder];
while (matchFound) {
const patternRegex = /\{\{([^}]+)\}\}/g;
matchFound = false;
resultStr = resultStr.replace(patternRegex, (match, placeholder) => {
const replacement = flattenedObj[placeholder];
if (results.has(match)) {
return results.get(match);
}
if (results.has(match)) {
return results.get(match);
}
if (patternRegex.test(replacement) && !visited.has(match)) {
visited.add(match);
const result = replace(replacement, flattenedObj, visited, results);
results.set(match, result);
matchFound = true;
return result;
}
if (patternRegex.test(replacement) && !visited.has(match)) {
visited.add(match);
const result = replace(replacement, flattenedObj, visited, results);
const result = replacement !== undefined ? replacement : match;
results.set(match, result);
matchFound = true;
return result;
}
});
}
visited.add(match);
const result = replacement !== undefined ? replacement : match;
results.set(match, result);
return result;
});
return resultStr;
};
export default interpolate;

View File

@@ -1,5 +1,5 @@
{
"version": "v1.25.0",
"version": "v1.26.2",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@@ -16,7 +16,7 @@
"dist:rpm": "electron-builder --linux rpm --config electron-builder-config.js",
"dist:snap": "electron-builder --linux snap --config electron-builder-config.js",
"pack": "electron-builder --dir",
"test": "jest"
"test": "node --experimental-vm-modules $(npx which jest)"
},
"jest": {
"modulePaths": ["node_modules"]
@@ -65,7 +65,6 @@
},
"devDependencies": {
"electron": "31.2.1",
"electron-builder": "23.0.2",
"electron-icon-maker": "^0.0.5"
"electron-builder": "23.0.2"
}
}

View File

@@ -464,9 +464,10 @@ class Watcher {
.on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath))
.on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath))
.on('error', (error) => {
// `EMFILE` is an error code thrown when to many files are watched at the same time see: https://github.com/usebruno/bruno/issues/627
// `ENOSPC` stands for "Error No space" but is also thrown if the file watcher limit is reached.
// To prevent loops `!forcePolling` is checked.
if (error.code === 'ENOSPC' && !startedNewWatcher && !forcePolling) {
if ((error.code === 'ENOSPC' || error.code === 'EMFILE') && !startedNewWatcher && !forcePolling) {
// This callback is called for every file the watcher is trying to watch. To prevent a spam of messages and
// Multiple watcher being started `startedNewWatcher` is set to prevent this.
startedNewWatcher = true;

View File

@@ -13,15 +13,19 @@ const {
browseFiles,
createDirectory,
searchForBruFiles,
sanitizeDirectoryName
sanitizeDirectoryName,
isWSLPath,
normalizeWslPath,
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -324,6 +328,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// rename item
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
try {
// Normalize paths if they are WSL paths
if (isWSLPath(oldPath)) {
oldPath = normalizeWslPath(oldPath);
}
if (isWSLPath(newPath)) {
newPath = normalizeWslPath(newPath);
}
if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`);
}
@@ -665,6 +677,24 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return Promise.reject(error);
}
});
ipcMain.handle('renderer:save-collection-security-config', async (event, collectionPath, securityConfig) => {
try {
collectionSecurityStore.setSecurityConfigForCollection(collectionPath, {
jsSandboxMode: securityConfig.jsSandboxMode
});
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:get-collection-security-config', async (event, collectionPath) => {
try {
return collectionSecurityStore.getSecurityConfigForCollection(collectionPath);
} catch (error) {
return Promise.reject(error);
}
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {

View File

@@ -29,7 +29,7 @@ const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
const { chooseFileToSave, writeBinaryFile } = require('../../utils/filesystem');
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
const {
resolveOAuth2AuthorizationCodeAccessToken,
@@ -81,6 +81,11 @@ const getEnvVars = (environment = {}) => {
};
};
const getJsSandboxRuntime = (collection) => {
const securityConfig = get(collection, 'securityConfig', {});
return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
};
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const configureRequest = async (
@@ -315,7 +320,7 @@ const registerNetworkIpc = (mainWindow) => {
// run pre-request vars
const preRequestVars = get(request, 'vars.req', []);
if (preRequestVars?.length) {
const varsRuntime = new VarsRuntime();
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPreRequestVars(
preRequestVars,
request,
@@ -330,7 +335,7 @@ const registerNetworkIpc = (mainWindow) => {
let scriptResult;
const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(os.EOL);
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime();
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runRequestScript(
decomment(requestScript),
request,
@@ -382,7 +387,7 @@ const registerNetworkIpc = (mainWindow) => {
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime();
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
const result = varsRuntime.runPostResponseVars(
postResponseVars,
request,
@@ -416,7 +421,7 @@ const registerNetworkIpc = (mainWindow) => {
let scriptResult;
if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime();
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runResponseScript(
decomment(responseScript),
request,
@@ -460,6 +465,7 @@ const registerNetworkIpc = (mainWindow) => {
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
try {
const controller = new AbortController();
@@ -575,7 +581,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions
const assertions = get(request, 'assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(
assertions,
request,
@@ -603,7 +609,7 @@ const registerNetworkIpc = (mainWindow) => {
]).join(os.EOL);
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
@@ -661,6 +667,7 @@ const registerNetworkIpc = (mainWindow) => {
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
await runPreRequest(
request,
@@ -766,6 +773,7 @@ const registerNetworkIpc = (mainWindow) => {
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collection.uid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
await runPreRequest(
request,
@@ -832,6 +840,7 @@ const registerNetworkIpc = (mainWindow) => {
const cancelTokenUid = uuid();
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
const collectionRoot = get(collection, 'root', {});
const abortController = new AbortController();
@@ -1028,7 +1037,7 @@ const registerNetworkIpc = (mainWindow) => {
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(
assertions,
request,
@@ -1055,7 +1064,7 @@ const registerNetworkIpc = (mainWindow) => {
]).join(os.EOL);
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
@@ -1180,7 +1189,13 @@ const registerNetworkIpc = (mainWindow) => {
const fileName = determineFileName();
const filePath = await chooseFileToSave(mainWindow, fileName);
if (filePath) {
await writeBinaryFile(filePath, Buffer.from(response.dataBuffer, getEncodingFormat()));
const encoding = getEncodingFormat();
const data = Buffer.from(response.dataBuffer, 'base64')
if (encoding === 'utf-8') {
await writeFile(filePath, data);
} else {
await writeBinaryFile(filePath, data);
}
}
} catch (error) {
return Promise.reject(error);

View File

@@ -32,9 +32,6 @@ const resolveOAuth2AuthorizationCodeAccessToken = async (request, collectionUid)
client_secret: clientSecret,
state: state
};
if (scope) {
data['scope'] = scope;
}
if (pkce) {
data['code_verifier'] = codeVerifier;
}

View File

@@ -0,0 +1,39 @@
const _ = require('lodash');
const Store = require('electron-store');
class CollectionSecurityStore {
constructor() {
this.store = new Store({
name: 'collection-security',
clearInvalidConfig: true
});
}
setSecurityConfigForCollection(collectionPathname, securityConfig) {
const collections = this.store.get('collections') || [];
const collection = _.find(collections, (c) => c.path === collectionPathname);
if (!collection) {
collections.push({
path: collectionPathname,
securityConfig: {
jsSandboxMode: securityConfig.jsSandboxMode
}
});
this.store.set('collections', collections);
return;
}
collection.securityConfig = securityConfig || {};
this.store.set('collections', collections);
}
getSecurityConfigForCollection(collectionPathname) {
const collections = this.store.get('collections') || [];
const collection = _.find(collections, (c) => c.path === collectionPathname);
return collection?.securityConfig || {};
}
}
module.exports = CollectionSecurityStore;

View File

@@ -86,7 +86,11 @@ function decryptString(str) {
}
if (algo === ELECTRONSAFESTORAGE_ALGO) {
return safeStorageDecrypt(encryptedString);
if (safeStorage && safeStorage.isEncryptionAvailable()) {
return safeStorageDecrypt(encryptedString);
} else {
return '';
}
}
if (algo === AES256_ALGO) {

View File

@@ -50,6 +50,18 @@ const normalizeAndResolvePath = (pathname) => {
return path.resolve(pathname);
};
function isWSLPath(pathname) {
// Check if the path starts with the WSL prefix
// eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru"
return pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost\\');
}
function normalizeWslPath(pathname) {
// Replace the WSL path prefix and convert forward slashes to backslashes
// This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion)
return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
}
const writeFile = async (pathname, content) => {
try {
fs.writeFileSync(pathname, content, {
@@ -143,6 +155,8 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
// const isW
const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
};
@@ -154,6 +168,8 @@ module.exports = {
isFile,
isDirectory,
normalizeAndResolvePath,
isWSLPath,
normalizeWslPath,
writeFile,
writeBinaryFile,
hasJsonExtension,

View File

@@ -22,7 +22,7 @@
"postcss": "^8.4.18",
"react": "18.2.0",
"react-dom": "18.2.0",
"rollup": "3.2.5",
"rollup":"3.29.4",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
@@ -34,6 +34,6 @@
"markdown-it": "^13.0.1"
},
"overrides": {
"rollup": "3.2.5"
"rollup":"3.29.4"
}
}

1
packages/bruno-js/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
src/sandbox/bundle-browser-rollup.js

View File

@@ -11,7 +11,8 @@
"@n8n/vm2": "^3.9.23"
},
"scripts": {
"test": "jest --testPathIgnorePatterns test.js"
"test": "node --experimental-vm-modules $(npx which jest) --testPathIgnorePatterns test.js",
"sandbox:bundle-libraries": "node ./src/sandbox/bundle-libraries.js"
},
"dependencies": {
"@usebruno/common": "0.1.0",
@@ -24,12 +25,29 @@
"chai": "^4.3.7",
"chai-string": "^1.5.0",
"crypto-js": "^4.1.1",
"crypto-js-3.1.9-1": "npm:crypto-js@^3.1.9-1",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.4",
"node-fetch": "2.*",
"node-fetch": "^2.7.0",
"node-vault": "^0.10.2",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"rollup": "3.2.5",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-polyfill-node": "^0.13.0",
"rollup-plugin-terser": "^7.0.2",
"stream": "^0.0.2",
"terser": "^5.31.1",
"uglify-js": "^3.18.0",
"util": "^0.12.5"
}
}

View File

@@ -100,6 +100,10 @@ class Bru {
setNextRequest(nextRequest) {
this.nextRequest = nextRequest;
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = Bru;

View File

@@ -5,6 +5,7 @@ const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
const { interpolateString } = require('../interpolate-string');
const { executeQuickJsVm } = require('../sandbox/quickjs');
const { expect } = chai;
chai.use(require('chai-string'));
@@ -161,7 +162,31 @@ const isUnaryOperator = (operator) => {
return unaryOperators.includes(operator);
};
const evaluateRhsOperand = (rhsOperand, operator, context) => {
const evaluateJsTemplateLiteralBasedOnRuntime = (literal, context, runtime) => {
if (runtime === 'quickjs') {
return executeQuickJsVm({
script: literal,
context,
scriptType: 'template-literal'
});
}
return evaluateJsTemplateLiteral(literal, context);
};
const evaluateJsExpressionBasedOnRuntime = (expr, context, runtime) => {
if (runtime === 'quickjs') {
return executeQuickJsVm({
script: expr,
context,
scriptType: 'expression'
});
}
return evaluateJsExpression(expr, context);
};
const evaluateRhsOperand = (rhsOperand, operator, context, runtime) => {
if (isUnaryOperator(operator)) {
return;
}
@@ -181,13 +206,17 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return rhsOperand
.split(',')
.map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
.map((v) =>
evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(v.trim(), interpolationContext), context, runtime)
);
}
if (operator === 'between') {
const [lhs, rhs] = rhsOperand
.split(',')
.map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
.map((v) =>
evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(v.trim(), interpolationContext), context, runtime)
);
return [lhs, rhs];
}
@@ -200,10 +229,14 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return interpolateString(rhsOperand, interpolationContext);
}
return evaluateJsTemplateLiteral(interpolateString(rhsOperand, interpolationContext), context);
return evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(rhsOperand, interpolationContext), context, runtime);
};
class AssertRuntime {
constructor(props) {
this.runtime = props?.runtime || 'vm2';
}
runAssertions(assertions, request, response, envVariables, runtimeVariables, processEnvVars) {
const requestVariables = request?.requestVariables || {};
const enabledAssertions = _.filter(assertions, (a) => a.enabled);
@@ -238,8 +271,8 @@ class AssertRuntime {
const { operator, value: rhsOperand } = parseAssertionOperator(rhsExpr);
try {
const lhs = evaluateJsExpression(lhsExpr, context);
const rhs = evaluateRhsOperand(rhsOperand, operator, context);
const lhs = evaluateJsExpressionBasedOnRuntime(lhsExpr, context, this.runtime);
const rhs = evaluateRhsOperand(rhsOperand, operator, context, this.runtime);
switch (operator) {
case 'eq':

View File

@@ -28,9 +28,12 @@ const fetch = require('node-fetch');
const chai = require('chai');
const CryptoJS = require('crypto-js');
const NodeVault = require('node-vault');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
class ScriptRuntime {
constructor() {}
constructor(props) {
this.runtime = props?.runtime || 'vm2';
}
// This approach is getting out of hand
// Need to refactor this to use a single arg (object) instead of 7
@@ -86,6 +89,22 @@ class ScriptRuntime {
};
}
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: script,
context: context,
collectionPath
});
return {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
nextRequestName: bru.nextRequest
};
}
// default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
@@ -123,6 +142,7 @@ class ScriptRuntime {
});
const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
await asyncVM();
return {
request,
envVariables: cleanJson(envVariables),
@@ -176,10 +196,27 @@ class ScriptRuntime {
log: customLogger('log'),
info: customLogger('info'),
warn: customLogger('warn'),
error: customLogger('error')
error: customLogger('error'),
debug: customLogger('debug')
};
}
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: script,
context: context,
collectionPath
});
return {
response,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
nextRequestName: bru.nextRequest
};
}
// default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {

View File

@@ -15,7 +15,7 @@ const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
const Test = require('../test');
const TestResults = require('../test-results');
const { cleanJson } = require('../utils');
const { cleanJson, appendAwaitToTestFunc } = require('../utils');
// Inbuilt Library Support
const ajv = require('ajv');
@@ -30,9 +30,12 @@ const axios = require('axios');
const fetch = require('node-fetch');
const CryptoJS = require('crypto-js');
const NodeVault = require('node-vault');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
class TestRuntime {
constructor() {}
constructor(props) {
this.runtime = props?.runtime || 'vm2';
}
async runTests(
testsFile,
@@ -81,6 +84,9 @@ class TestRuntime {
};
}
// add 'await' prefix to the test function calls
testsFile = appendAwaitToTestFunc(testsFile);
const context = {
test,
bru,
@@ -101,48 +107,56 @@ class TestRuntime {
log: customLogger('log'),
info: customLogger('info'),
warn: customLogger('warn'),
debug: customLogger('debug'),
error: customLogger('error')
};
}
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
ajv,
'ajv-formats': addFormats,
btoa,
atob,
lodash,
moment,
uuid,
nanoid,
axios,
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: testsFile,
context: context
});
} else {
// default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
ajv,
'ajv-formats': addFormats,
btoa,
atob,
lodash,
moment,
uuid,
nanoid,
axios,
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
}
}
}
});
const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
await asyncVM();
});
const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
await asyncVM();
}
return {
request,

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