Merge branch 'main' into main

This commit is contained in:
Anoop M D 2023-10-06 17:36:35 +05:30 committed by GitHub
commit d5a6522563
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 878 additions and 308 deletions

View File

@ -29,5 +29,7 @@ jobs:
run: npm run test --workspace=packages/bruno-app run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-js - name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-electron - name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron run: npm run test --workspace=packages/bruno-electron

View File

@ -1,3 +1,5 @@
**English** | [Русский](/contributing_ru.md)
## Lets make bruno better, together !! ## Lets make bruno better, together !!
I am happy that you are looking to improve bruno. Below are the guidelines to get started bringing up bruno on your computer. I am happy that you are looking to improve bruno. Below are the guidelines to get started bringing up bruno on your computer.

37
contributing_ru.md Normal file
View File

@ -0,0 +1,37 @@
[English](/contributing.md) | **Русский**
## Давайте вместе сделаем Бруно лучше!!!
Я рад, что Вы хотите усовершенствовать bruno. Ниже приведены рекомендации по запуску bruno на вашем компьютере.
### Стек
Bruno построен с использованием NextJs и React. Мы также используем electron для поставки десктопной версии ( которая поддерживает локальные коллекции )
Библиотеки, которые мы используем
- CSS - Tailwind
- Редакторы кода - Codemirror
- Управление состоянием - Redux
- Иконки - Tabler Icons
- Формы - formik
- Валидация схем - Yup
- Запросы клиента - axios
- Наблюдатель за файловой системой - chokidar
### Зависимости
Вам потребуется [Node v18.x или последняя версия LTS](https://nodejs.org/en/) и npm 8.x. В проекте мы используем рабочие пространства npm
### Приступим к коду
Пожалуйста, обратитесь к [development_ru.md](docs/development_ru.md) для получения инструкций по запуску локальной среды разработки.
### Создание Pull Request
- Пожалуйста, пусть PR будет небольшим и сфокусированным на одной вещи
- Пожалуйста, соблюдайте формат создания веток
- feature/[название функции]: Эта ветка должна содержать изменения для конкретной функции
- Пример: feature/dark-mode
- bugfix/[название ошибки]: Эта ветка должна содержать только исправления для конкретной ошибки
- Пример bugfix/bug-1

View File

@ -1,3 +1,5 @@
**English** | [Русский](/docs/development_ru.md)
## Development ## Development
Bruno is being developed as a desktop app. You need to load the app by running the nextjs app in one terminal and then run the electron app in another terminal. Bruno is being developed as a desktop app. You need to load the app by running the nextjs app in one terminal and then run the electron app in another terminal.

55
docs/development_ru.md Normal file
View File

@ -0,0 +1,55 @@
[English](/docs/development.md) | **Русский**
## Разработка
Bruno разрабатывается как десктопное приложение. Необходимо загрузить приложение, запустив приложение nextjs в одном терминале, а затем запустить приложение electron в другом терминале.
### Зависимости
- NodeJS v18
### Локальная разработка
```bash
# используйте nodejs 18 версии
nvm use
# установите зависимости
npm i --legacy-peer-deps
# билд документации по graphql
npm run build:graphql-docs
# билд bruno query
npm run build:bruno-query
# запустить next приложение ( терминал 1 )
npm run dev:web
# запустить приложение electron ( терминал 2 )
npm run dev:electron
```
### Устранение неисправностей
При запуске `npm install` может возникнуть ошибка `Unsupported platform`. Чтобы исправить это, необходимо удалить `node_modules` и `package-lock.json` и запустить `npm install`. В результате будут установлены все пакеты, необходимые для работы приложения.
```shell
# Удаление node_modules в подкаталогах
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# Удаление package-lock в подкаталогах
find . -type f -name "package-lock.json" -delete
```
### Тестирование
```bash
# bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
```

36
package-lock.json generated
View File

@ -6678,6 +6678,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/decomment": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/decomment/-/decomment-0.9.5.tgz",
"integrity": "sha512-h0TZ8t6Dp49duwyDHo3iw67mnh9/UpFiSSiOb5gDK1sqoXzrfX/SQxIUQd2R2QEiSnqib0KF2fnKnGfAhAs6lg==",
"dependencies": {
"esprima": "4.0.1"
},
"engines": {
"node": ">=6.4",
"npm": ">=2.15"
}
},
"node_modules/decompress-response": { "node_modules/decompress-response": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@ -7505,7 +7517,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"bin": { "bin": {
"esparse": "bin/esparse.js", "esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js" "esvalidate": "bin/esvalidate.js"
@ -16765,7 +16776,7 @@
}, },
"packages/bruno-cli": { "packages/bruno-cli": {
"name": "@usebruno/cli", "name": "@usebruno/cli",
"version": "0.10.1", "version": "0.11.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@usebruno/js": "0.6.0", "@usebruno/js": "0.6.0",
@ -16773,6 +16784,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chalk": "^3.0.0", "chalk": "^3.0.0",
"decomment": "^0.9.5",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
@ -16810,7 +16822,7 @@
}, },
"packages/bruno-electron": { "packages/bruno-electron": {
"name": "bruno", "name": "bruno",
"version": "v0.18.0", "version": "v0.19.0",
"dependencies": { "dependencies": {
"@usebruno/js": "0.6.0", "@usebruno/js": "0.6.0",
"@usebruno/lang": "0.5.0", "@usebruno/lang": "0.5.0",
@ -16819,6 +16831,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"decomment": "^0.9.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2", "electron-notarize": "^1.2.2",
@ -16848,7 +16861,9 @@
"dmg-license": "^1.0.11" "dmg-license": "^1.0.11"
} }
}, },
"packages/bruno-electron/0.5cd ../.0": {}, "packages/bruno-electron/0.5cd ../.0": {
"extraneous": true
},
"packages/bruno-electron/node_modules/@types/node": { "packages/bruno-electron/node_modules/@types/node": {
"version": "16.18.11", "version": "16.18.11",
"dev": true, "dev": true,
@ -20092,6 +20107,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chalk": "^3.0.0", "chalk": "^3.0.0",
"decomment": "*",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
@ -21201,6 +21217,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"decomment": "^0.9.5",
"dmg-license": "^1.0.11", "dmg-license": "^1.0.11",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"electron": "21.1.1", "electron": "21.1.1",
@ -22309,6 +22326,14 @@
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true "dev": true
}, },
"decomment": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/decomment/-/decomment-0.9.5.tgz",
"integrity": "sha512-h0TZ8t6Dp49duwyDHo3iw67mnh9/UpFiSSiOb5gDK1sqoXzrfX/SQxIUQd2R2QEiSnqib0KF2fnKnGfAhAs6lg==",
"requires": {
"esprima": "4.0.1"
}
},
"decompress-response": { "decompress-response": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@ -22958,8 +22983,7 @@
"esprima": { "esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
"dev": true
}, },
"esrecurse": { "esrecurse": {
"version": "4.3.0", "version": "4.3.0",

View File

@ -6,6 +6,7 @@ import { IconRefresh, IconLoader2, IconBook, IconDownload } from '@tabler/icons'
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryEditor from 'components/RequestPane/QueryEditor'; import QueryEditor from 'components/RequestPane/QueryEditor';
import Auth from 'components/RequestPane/Auth';
import GraphQLVariables from 'components/RequestPane/GraphQLVariables'; import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
import RequestHeaders from 'components/RequestPane/RequestHeaders'; import RequestHeaders from 'components/RequestPane/RequestHeaders';
import Vars from 'components/RequestPane/Vars'; import Vars from 'components/RequestPane/Vars';
@ -32,7 +33,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let { schema, loadSchema, isLoading: isSchemaLoading, error: schemaError } = useGraphqlSchema(url, environment); const request = item.draft ? item.draft.request : item.request;
let {
schema,
loadSchema,
isLoading: isSchemaLoading,
error: schemaError
} = useGraphqlSchema(url, environment, request, collection.collectionVariables);
const loadGqlSchema = () => { const loadGqlSchema = () => {
if (!isSchemaLoading) { if (!isSchemaLoading) {
@ -90,6 +98,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'headers': { case 'headers': {
return <RequestHeaders item={item} collection={collection} />; return <RequestHeaders item={item} collection={collection} />;
} }
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': { case 'vars': {
return <Vars item={item} collection={collection} />; return <Vars item={item} collection={collection} />;
} }
@ -135,6 +146,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}> <div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers Headers
</div> </div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}> <div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars Vars
</div> </div>

View File

@ -6,7 +6,7 @@ import { simpleHash } from 'utils/common';
const schemaHashPrefix = 'bruno.graphqlSchema'; const schemaHashPrefix = 'bruno.graphqlSchema';
const useGraphqlSchema = (endpoint, environment) => { const useGraphqlSchema = (endpoint, environment, request, collectionVariables) => {
const localStorageKey = `${schemaHashPrefix}.${simpleHash(endpoint)}`; const localStorageKey = `${schemaHashPrefix}.${simpleHash(endpoint)}`;
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -25,7 +25,7 @@ const useGraphqlSchema = (endpoint, environment) => {
const loadSchema = () => { const loadSchema = () => {
setIsLoading(true); setIsLoading(true);
fetchGqlSchema(endpoint, environment) fetchGqlSchema(endpoint, environment, request, collectionVariables)
.then((res) => res.data) .then((res) => res.data)
.then((s) => { .then((s) => {
if (s && s.data) { if (s && s.data) {

View File

@ -105,7 +105,7 @@ const Sidebar = () => {
Star Star
</GitHubButton> </GitHubButton>
</div> </div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.19.0</div> <div className="flex flex-grow items-center justify-end text-xs mr-2">v0.20.0</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import useTelemetry from './useTelemetry'; import useTelemetry from './useTelemetry';
import useCollectionTreeSync from './useCollectionTreeSync'; import useCollectionTreeSync from './useCollectionTreeSync';
import useCollectionNextAction from './useCollectionNextAction';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -10,6 +11,7 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => { export const AppProvider = (props) => {
useTelemetry(); useTelemetry();
useCollectionTreeSync(); useCollectionTreeSync();
useCollectionNextAction();
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@ -0,0 +1,35 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import each from 'lodash/each';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab, findItemInCollectionByPathname } from 'utils/collections/index';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { updateNextAction } from 'providers/ReduxStore/slices/collections/index';
import { useSelector, useDispatch } from 'react-redux';
const useCollectionNextAction = () => {
const collections = useSelector((state) => state.collections.collections);
const dispatch = useDispatch();
useEffect(() => {
each(collections, (collection) => {
if (collection.nextAction && collection.nextAction.type === 'OPEN_REQUEST') {
const item = findItemInCollectionByPathname(collection, get(collection, 'nextAction.payload.pathname'));
if (item) {
dispatch(updateNextAction(collection.uid, null));
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item.type)
})
);
dispatch(hideHomePage());
}
}
});
}, [collections, each, dispatch, updateNextAction, hideHomePage, addTab]);
};
export default useCollectionNextAction;

View File

@ -26,6 +26,7 @@ import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import { import {
updateLastAction, updateLastAction,
updateNextAction,
resetRunResults, resetRunResults,
requestCancelled, requestCancelled,
responseReceived, responseReceived,
@ -595,6 +596,19 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject); ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
// the useCollectionNextAction() will track this and open the new request in a new tab
// once the request is created
dispatch(
updateNextAction({
nextAction: {
type: 'OPEN_REQUEST',
payload: {
pathname: fullName
}
},
collectionUid
})
);
} else { } else {
return reject(new Error('Duplicate request names are not allowed under the same folder')); return reject(new Error('Duplicate request names are not allowed under the same folder'));
} }
@ -612,6 +626,20 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject); ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
// the useCollectionNextAction() will track this and open the new request in a new tab
// once the request is created
dispatch(
updateNextAction({
nextAction: {
type: 'OPEN_REQUEST',
payload: {
pathname: fullName
}
},
collectionUid
})
);
} else { } else {
return reject(new Error('Duplicate request names are not allowed under the same folder')); return reject(new Error('Duplicate request names are not allowed under the same folder'));
} }

View File

@ -39,6 +39,8 @@ export const collectionsSlice = createSlice({
createCollection: (state, action) => { createCollection: (state, action) => {
const collectionUids = map(state.collections, (c) => c.uid); const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload; const collection = action.payload;
// TODO: move this to use the nextAction approach
// last action is used to track the last action performed on the collection // last action is used to track the last action performed on the collection
// this is optional // this is optional
// this is used in scenarios where we want to know the last action performed on the collection // this is used in scenarios where we want to know the last action performed on the collection
@ -47,6 +49,10 @@ export const collectionsSlice = createSlice({
collection.importedAt = new Date().getTime(); collection.importedAt = new Date().getTime();
collection.lastAction = null; collection.lastAction = null;
// an improvement over the above approach.
// this defines an action that need to be performed next and is executed vy the useCollectionNextAction()
collection.nextAction = null;
collapseCollection(collection); collapseCollection(collection);
addDepth(collection.items); addDepth(collection.items);
if (!collectionUids.includes(collection.uid)) { if (!collectionUids.includes(collection.uid)) {
@ -93,6 +99,14 @@ export const collectionsSlice = createSlice({
collection.lastAction = lastAction; collection.lastAction = lastAction;
} }
}, },
updateNextAction: (state, action) => {
const { collectionUid, nextAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.nextAction = nextAction;
}
},
collectionUnlinkEnvFileEvent: (state, action) => { collectionUnlinkEnvFileEvent: (state, action) => {
const { data: environment, meta } = action.payload; const { data: environment, meta } = action.payload;
const collection = findCollectionByUid(state.collections, meta.collectionUid); const collection = findCollectionByUid(state.collections, meta.collectionUid);
@ -1197,6 +1211,7 @@ export const {
removeCollection, removeCollection,
sortCollections, sortCollections,
updateLastAction, updateLastAction,
updateNextAction,
collectionUnlinkEnvFileEvent, collectionUnlinkEnvFileEvent,
saveEnvironment, saveEnvironment,
selectEnvironment, selectEnvironment,

View File

@ -41,6 +41,16 @@ const addSuffixToDuplicateName = (item, index, allItems) => {
return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name; return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
}; };
const regexVariable = new RegExp('{{.*?}}', 'g');
const normalizeVariables = (value) => {
const variables = value.match(regexVariable) || [];
each(variables, (variable) => {
value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
});
return value;
};
const transformInsomniaRequestItem = (request, index, allRequests) => { const transformInsomniaRequestItem = (request, index, allRequests) => {
const name = addSuffixToDuplicateName(request, index, allRequests); const name = addSuffixToDuplicateName(request, index, allRequests);
@ -51,6 +61,11 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
request: { request: {
url: request.url, url: request.url,
method: request.method, method: request.method,
auth: {
mode: 'none',
basic: null,
bearer: null
},
headers: [], headers: [],
params: [], params: [],
body: { body: {
@ -84,7 +99,22 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
}); });
}); });
const mimeType = get(request, 'body.mimeType', ''); const authType = get(request, 'authentication.type', '');
if (authType === 'basic') {
brunoRequestItem.request.auth.mode = 'basic';
brunoRequestItem.request.auth.basic = {
username: normalizeVariables(get(request, 'authentication.username', '')),
password: normalizeVariables(get(request, 'authentication.password', ''))
};
} else if (authType === 'bearer') {
brunoRequestItem.request.auth.mode = 'bearer';
brunoRequestItem.request.auth.bearer = {
token: normalizeVariables(get(request, 'authentication.token', ''))
};
}
const mimeType = get(request, 'body.mimeType', '').split(';')[0];
if (mimeType === 'application/json') { if (mimeType === 'application/json') {
brunoRequestItem.request.body.mode = 'json'; brunoRequestItem.request.body.mode = 'json';

View File

@ -29,11 +29,14 @@ const sendHttpRequest = async (item, collection, environment, collectionVariable
}); });
}; };
export const fetchGqlSchema = async (endpoint, environment) => { export const fetchGqlSchema = async (endpoint, environment, request, collectionVariables) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('fetch-gql-schema', endpoint, environment).then(resolve).catch(reject); ipcRenderer
.invoke('fetch-gql-schema', endpoint, environment, request, collectionVariables)
.then(resolve)
.catch(reject);
}); });
}; };

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.12.0
- show response time in milliseconds per request and total
## 0.11.0 ## 0.11.0
- fix(#119) Support for Basic and Bearer Auth - fix(#119) Support for Basic and Bearer Auth

View File

@ -1,8 +1,11 @@
{ {
"summary": { "summary": {
"totalRequests": 10,
"passedRequests": 10,
"failedRequests": 0,
"totalAssertions": 4, "totalAssertions": 4,
"passedAssertions": 4, "passedAssertions": 0,
"failedAssertions": 0, "failedAssertions": 4,
"totalTests": 0, "totalTests": 0,
"passedTests": 0, "passedTests": 0,
"failedTests": 0 "failedTests": 0
@ -11,53 +14,33 @@
{ {
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8080/test/v4", "url": "http://localhost:3000/test/v4",
"headers": {} "headers": {}
}, },
"response": { "response": {
"status": 200, "status": 404,
"statusText": "OK", "statusText": "Not Found",
"headers": { "headers": {
"x-powered-by": "Express", "x-powered-by": "Express",
"content-type": "application/json; charset=utf-8", "content-security-policy": "default-src 'none'",
"content-length": "497", "x-content-type-options": "nosniff",
"etag": "W/\"1f1-08gGpUcq2NTnMCVT5AuXxQ0DzGE\"", "content-type": "text/html; charset=utf-8",
"date": "Mon, 25 Sep 2023 21:43:02 GMT", "content-length": "146",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close" "connection": "close"
}, },
"data": { "data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v4</pre>\n</body>\n</html>\n"
"path": "/test/v4",
"headers": {
"accept": "application/json, text/plain, */*",
"user-agent": "axios/1.5.0",
"accept-encoding": "gzip, compress, deflate, br",
"host": "localhost:8080",
"connection": "close"
},
"method": "GET",
"body": "",
"fresh": false,
"hostname": "localhost",
"ip": "",
"ips": [],
"protocol": "http",
"query": {},
"subdomains": [],
"xhr": false,
"os": {
"hostname": "05512cb2102c"
},
"connection": {}
}
}, },
"error": null,
"assertionResults": [ "assertionResults": [
{ {
"uid": "mTrKBl5YU6jiAVG-phKT4", "uid": "oidgfXLiyD8Jv0NBAHUHF",
"lhsExpr": "res.status", "lhsExpr": "res.status",
"rhsExpr": "200", "rhsExpr": "200",
"rhsOperand": "200", "rhsOperand": "200",
"operator": "eq", "operator": "eq",
"status": "pass" "status": "fail",
"error": "expected 404 to equal 200"
} }
], ],
"testResults": [] "testResults": []
@ -65,53 +48,33 @@
{ {
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8080/test/v2", "url": "http://localhost:3000/test/v2",
"headers": {} "headers": {}
}, },
"response": { "response": {
"status": 200, "status": 404,
"statusText": "OK", "statusText": "Not Found",
"headers": { "headers": {
"x-powered-by": "Express", "x-powered-by": "Express",
"content-type": "application/json; charset=utf-8", "content-security-policy": "default-src 'none'",
"content-length": "497", "x-content-type-options": "nosniff",
"etag": "W/\"1f1-lMqxZgVOJiQXjF5yk3AFEU8O9Ro\"", "content-type": "text/html; charset=utf-8",
"date": "Mon, 25 Sep 2023 21:43:02 GMT", "content-length": "146",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close" "connection": "close"
}, },
"data": { "data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v2</pre>\n</body>\n</html>\n"
"path": "/test/v2",
"headers": {
"accept": "application/json, text/plain, */*",
"user-agent": "axios/1.5.0",
"accept-encoding": "gzip, compress, deflate, br",
"host": "localhost:8080",
"connection": "close"
},
"method": "GET",
"body": "",
"fresh": false,
"hostname": "localhost",
"ip": "",
"ips": [],
"protocol": "http",
"query": {},
"subdomains": [],
"xhr": false,
"os": {
"hostname": "05512cb2102c"
},
"connection": {}
}
}, },
"error": null,
"assertionResults": [ "assertionResults": [
{ {
"uid": "XsjjGx9cjt5t8tE_t69ZB", "uid": "IgliYuHd9wKp6JNyqyHFK",
"lhsExpr": "res.status", "lhsExpr": "res.status",
"rhsExpr": "200", "rhsExpr": "200",
"rhsOperand": "200", "rhsOperand": "200",
"operator": "eq", "operator": "eq",
"status": "pass" "status": "fail",
"error": "expected 404 to equal 200"
} }
], ],
"testResults": [] "testResults": []
@ -119,53 +82,33 @@
{ {
"request": { "request": {
"method": "GET", "method": "GET",
"url": "http://localhost:8080/test/v3", "url": "http://localhost:3000/test/v3",
"headers": {} "headers": {}
}, },
"response": { "response": {
"status": 200, "status": 404,
"statusText": "OK", "statusText": "Not Found",
"headers": { "headers": {
"x-powered-by": "Express", "x-powered-by": "Express",
"content-type": "application/json; charset=utf-8", "content-security-policy": "default-src 'none'",
"content-length": "497", "x-content-type-options": "nosniff",
"etag": "W/\"1f1-tSiYu0/vWz3r+NYRCaed0aW1waw\"", "content-type": "text/html; charset=utf-8",
"date": "Mon, 25 Sep 2023 21:43:02 GMT", "content-length": "146",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close" "connection": "close"
}, },
"data": { "data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v3</pre>\n</body>\n</html>\n"
"path": "/test/v3",
"headers": {
"accept": "application/json, text/plain, */*",
"user-agent": "axios/1.5.0",
"accept-encoding": "gzip, compress, deflate, br",
"host": "localhost:8080",
"connection": "close"
},
"method": "GET",
"body": "",
"fresh": false,
"hostname": "localhost",
"ip": "",
"ips": [],
"protocol": "http",
"query": {},
"subdomains": [],
"xhr": false,
"os": {
"hostname": "05512cb2102c"
},
"connection": {}
}
}, },
"error": null,
"assertionResults": [ "assertionResults": [
{ {
"uid": "i_8MmDMtJA9YfvB_FrW15", "uid": "u-3sRebrCyuUbZOkwS0z8",
"lhsExpr": "res.status", "lhsExpr": "res.status",
"rhsExpr": "200", "rhsExpr": "200",
"rhsOperand": "200", "rhsOperand": "200",
"operator": "eq", "operator": "eq",
"status": "pass" "status": "fail",
"error": "expected 404 to equal 200"
} }
], ],
"testResults": [] "testResults": []
@ -173,7 +116,7 @@
{ {
"request": { "request": {
"method": "POST", "method": "POST",
"url": "http://localhost:8080/test/v1", "url": "http://localhost:3000/test/v1",
"headers": { "headers": {
"content-type": "application/json" "content-type": "application/json"
}, },
@ -181,57 +124,201 @@
"test": "hello" "test": "hello"
} }
}, },
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "147",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test/v1</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [
{
"uid": "PpKLK6I38I5_ibw4lZqLb",
"lhsExpr": "res.status",
"rhsExpr": "eq 200",
"rhsOperand": "200",
"operator": "eq",
"status": "fail",
"error": "expected 404 to equal 200"
}
],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/test",
"headers": {}
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "144",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "HEAD",
"url": "http://localhost:3000/",
"headers": {}
},
"response": { "response": {
"status": 200, "status": 200,
"statusText": "OK", "statusText": "OK",
"headers": { "headers": {
"x-powered-by": "Express", "x-powered-by": "Express",
"content-type": "application/json; charset=utf-8", "content-type": "text/html; charset=utf-8",
"content-length": "623", "content-length": "12",
"etag": "W/\"26f-ku5QGz4p9f02u79vJIve7JH3QYM\"", "etag": "W/\"c-Lve95gjOVATpfV8EL5X4nxwjKHE\"",
"date": "Mon, 25 Sep 2023 21:43:02 GMT", "date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close" "connection": "close"
}, },
"data": ""
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000",
"headers": {}
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "140",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/",
"headers": {
"content-type": "multipart/form-data; boundary=--------------------------897965859410704836065858"
},
"data": { "data": {
"path": "/test/v1", "_overheadLength": 103,
"headers": { "_valueLength": 3,
"accept": "application/json, text/plain, */*", "_valuesToMeasure": [],
"content-type": "application/json", "writable": false,
"user-agent": "axios/1.5.0", "readable": true,
"content-length": "16", "dataSize": 0,
"accept-encoding": "gzip, compress, deflate, br", "maxDataSize": 2097152,
"host": "localhost:8080", "pauseStreams": true,
"connection": "close" "_released": true,
}, "_streams": [],
"method": "POST", "_currentStream": null,
"body": "{\"test\":\"hello\"}", "_insideLoop": false,
"fresh": false, "_pendingNext": false,
"hostname": "localhost", "_boundary": "--------------------------897965859410704836065858",
"ip": "", "_events": {},
"ips": [], "_eventsCount": 3
"protocol": "http",
"query": {},
"subdomains": [],
"xhr": false,
"os": {
"hostname": "05512cb2102c"
},
"connection": {},
"json": {
"test": "hello"
}
} }
}, },
"assertionResults": [ "response": {
{ "status": 404,
"uid": "hNBSF_GBdSTFHNiyCcOn9", "statusText": "Not Found",
"lhsExpr": "res.status", "headers": {
"rhsExpr": "200", "x-powered-by": "Express",
"rhsOperand": "200", "content-security-policy": "default-src 'none'",
"operator": "eq", "x-content-type-options": "nosniff",
"status": "pass" "content-type": "text/html; charset=utf-8",
} "content-length": "140",
], "date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/",
"headers": {
"content-type": "application/x-www-form-urlencoded"
},
"data": "a=b&c=d"
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "140",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/test",
"headers": {
"content-type": "text/xml"
},
"data": "<xml>\n <test>1</test>\n</xml>"
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "144",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": [] "testResults": []
} }
] ]

View File

@ -1,6 +1,6 @@
{ {
"name": "@usebruno/cli", "name": "@usebruno/cli",
"version": "0.11.0", "version": "0.12.0",
"license": "MIT", "license": "MIT",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -13,6 +13,9 @@
"type": "git", "type": "git",
"url": "git+https://github.com/usebruno/bruno.git" "url": "git+https://github.com/usebruno/bruno.git"
}, },
"scripts": {
"test": "jest"
},
"files": [ "files": [
"src", "src",
"bin", "bin",
@ -26,6 +29,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chalk": "^3.0.0", "chalk": "^3.0.0",
"decomment": "^0.9.5",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",

View File

@ -12,17 +12,56 @@ const { dotenvToJson } = require('@usebruno/lang');
const command = 'run [filename]'; const command = 'run [filename]';
const desc = 'Run a request'; const desc = 'Run a request';
const printRunSummary = (assertionResults, testResults) => { const printRunSummary = (results) => {
// display assertion results and test results summary let totalRequests = 0;
const totalAssertions = assertionResults.length; let passedRequests = 0;
const passedAssertions = assertionResults.filter((result) => result.status === 'pass').length; let failedRequests = 0;
const failedAssertions = totalAssertions - passedAssertions; let totalAssertions = 0;
let passedAssertions = 0;
let failedAssertions = 0;
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
for (const result of results) {
totalRequests += 1;
totalTests += result.testResults.length;
totalAssertions += result.assertionResults.length;
let anyFailed = false;
let hasAnyTestsOrAssertions = false;
for (const testResult of result.testResults) {
hasAnyTestsOrAssertions = true;
if (testResult.status === 'pass') {
passedTests += 1;
} else {
anyFailed = true;
failedTests += 1;
}
}
for (const assertionResult of result.assertionResults) {
hasAnyTestsOrAssertions = true;
if (assertionResult.status === 'pass') {
passedAssertions += 1;
} else {
anyFailed = true;
failedAssertions += 1;
}
}
if (!hasAnyTestsOrAssertions && result.error) {
failedRequests += 1;
} else {
passedRequests += 1;
}
}
const totalTests = testResults.length;
const passedTests = testResults.filter((result) => result.status === 'pass').length;
const failedTests = totalTests - passedTests;
const maxLength = 12; const maxLength = 12;
let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`;
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
requestSummary += `, ${totalRequests} total`;
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`; let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
if (failedTests > 0) { if (failedTests > 0) {
assertSummary += `, ${chalk.red(`${failedTests} failed`)}`; assertSummary += `, ${chalk.red(`${failedTests} failed`)}`;
@ -35,10 +74,14 @@ const printRunSummary = (assertionResults, testResults) => {
} }
testSummary += `, ${totalAssertions} total`; testSummary += `, ${totalAssertions} total`;
console.log('\n' + chalk.bold(assertSummary)); console.log('\n' + chalk.bold(requestSummary));
console.log(chalk.bold(assertSummary));
console.log(chalk.bold(testSummary)); console.log(chalk.bold(testSummary));
return { return {
totalRequests,
passedRequests,
failedRequests,
totalAssertions, totalAssertions,
passedAssertions, passedAssertions,
failedAssertions, failedAssertions,
@ -255,9 +298,7 @@ const handler = async function (argv) {
} }
const _isFile = await isFile(filename); const _isFile = await isFile(filename);
let assertionResults = []; let results = [];
let testResults = [];
let testrunResults = [];
let bruJsons = []; let bruJsons = [];
@ -311,17 +352,12 @@ const handler = async function (argv) {
brunoConfig brunoConfig
); );
if (result) { results.push(result);
testrunResults.push(result);
const { assertionResults: _assertionResults, testResults: _testResults } = result;
assertionResults = assertionResults.concat(_assertionResults);
testResults = testResults.concat(_testResults);
}
} }
const summary = printRunSummary(assertionResults, testResults); const summary = printRunSummary(results);
console.log(chalk.dim(chalk.grey('Ran all requests.'))); const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
if (outputPath && outputPath.length) { if (outputPath && outputPath.length) {
const outputDir = path.dirname(outputPath); const outputDir = path.dirname(outputPath);
@ -333,14 +369,14 @@ const handler = async function (argv) {
const outputJson = { const outputJson = {
summary, summary,
results: testrunResults results
}; };
fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2));
console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`))); console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`)));
} }
if (summary.failedAssertions > 0 || summary.failedTests > 0) { if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {
process.exit(1); process.exit(1);
} }
} catch (err) { } catch (err) {
@ -354,5 +390,6 @@ module.exports = {
command, command,
desc, desc,
builder, builder,
handler handler,
printRunSummary
}; };

View File

@ -1,4 +1,5 @@
const { get, each, filter } = require('lodash'); const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const prepareRequest = (request) => { const prepareRequest = (request) => {
const headers = {}; const headers = {};
@ -39,7 +40,7 @@ const prepareRequest = (request) => {
axiosRequest.headers['content-type'] = 'application/json'; axiosRequest.headers['content-type'] = 'application/json';
} }
try { try {
axiosRequest.data = JSON.parse(request.body.json); axiosRequest.data = JSON.parse(decomment(request.body.json));
} catch (ex) { } catch (ex) {
axiosRequest.data = request.body.json; axiosRequest.data = request.body.json;
} }
@ -78,7 +79,7 @@ const prepareRequest = (request) => {
if (request.body.mode === 'graphql') { if (request.body.mode === 'graphql') {
const graphqlQuery = { const graphqlQuery = {
query: get(request, 'body.graphql.query'), query: get(request, 'body.graphql.query'),
variables: JSON.parse(get(request, 'body.graphql.variables') || '{}') variables: JSON.parse(decomment(get(request, 'body.graphql.variables') || '{}'))
}; };
if (!contentTypeDefined) { if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json'; axiosRequest.headers['content-type'] = 'application/json';

View File

@ -1,9 +1,11 @@
const qs = require('qs'); const qs = require('qs');
const chalk = require('chalk'); const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs'); const fs = require('fs');
const { forOwn, each, extend, get } = require('lodash'); const { forOwn, each, extend, get } = require('lodash');
const FormData = require('form-data'); const FormData = require('form-data');
const axios = require('axios'); const axios = requir
e('axios');
const prepareRequest = require('./prepare-request'); const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars'); const interpolateVars = require('./interpolate-vars');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js');
@ -12,6 +14,7 @@ const { getOptions } = require('../utils/bru');
const https = require('https'); const https = require('https');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const runSingleRequest = async function ( const runSingleRequest = async function (
filename, filename,
@ -22,9 +25,9 @@ const runSingleRequest = async function (
processEnvVars, processEnvVars,
brunoConfig brunoConfig
) { ) {
let request;
try { try {
let request;
request = prepareRequest(bruJson.request); request = prepareRequest(bruJson.request);
// make axios work in node using form data // make axios work in node using form data
@ -57,7 +60,7 @@ const runSingleRequest = async function (
if (requestScriptFile && requestScriptFile.length) { if (requestScriptFile && requestScriptFile.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runRequestScript( await scriptRuntime.runRequestScript(
requestScriptFile, decomment(requestScriptFile),
request, request,
envVariables, envVariables,
collectionVariables, collectionVariables,
@ -124,10 +127,48 @@ const runSingleRequest = async function (
request.data = qs.stringify(request.data); request.data = qs.stringify(request.data);
} }
// run request let response, responseTime;
const response = await axios(request); try {
// run request
const axiosInstance = makeAxiosInstance();
console.log(chalk.green(stripExtension(filename)) + chalk.dim(` (${response.status} ${response.statusText})`)); /** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} catch (err) {
if (err && err.response) {
response = err.response;
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
return {
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: null,
statusText: null,
headers: null,
data: null,
responseTime: 0
},
error: err.message,
assertionResults: [],
testResults: []
};
}
}
console.log(chalk.green(stripExtension(filename)) + chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`));
// run post-response vars // run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res'); const postResponseVars = get(bruJson, 'request.vars.res');
@ -149,7 +190,7 @@ const runSingleRequest = async function (
if (responseScriptFile && responseScriptFile.length) { if (responseScriptFile && responseScriptFile.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runResponseScript( await scriptRuntime.runResponseScript(
responseScriptFile, decomment(responseScriptFile),
request, request,
response, response,
envVariables, envVariables,
@ -190,7 +231,7 @@ const runSingleRequest = async function (
if (typeof testFile === 'string') { if (typeof testFile === 'string') {
const testRuntime = new TestRuntime(); const testRuntime = new TestRuntime();
const result = await testRuntime.runTests( const result = await testRuntime.runTests(
testFile, decomment(testFile),
request, request,
response, response,
envVariables, envVariables,
@ -223,102 +264,32 @@ const runSingleRequest = async function (
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: response.headers,
data: response.data data: response.data,
responseTime
}, },
error: null,
assertionResults, assertionResults,
testResults testResults
}; };
} catch (err) { } catch (err) {
if (err && err.response) { return {
console.log( request: {
chalk.green(stripExtension(filename)) + chalk.dim(` (${err.response.status} ${err.response.statusText})`) method: null,
); url: null,
headers: null,
// run post-response vars data: null
const postResponseVars = get(bruJson, 'request.vars.res'); },
if (postResponseVars && postResponseVars.length) { response: {
const varsRuntime = new VarsRuntime(); status: null,
varsRuntime.runPostResponseVars( statusText: null,
postResponseVars, headers: null,
request, data: null,
err.response, responseTime: 0
envVariables, },
collectionVariables, error: err.message,
collectionPath, assertionResults: [],
processEnvVars testResults: []
); };
}
// run post response script
const responseScriptFile = get(bruJson, 'request.script.res');
if (responseScriptFile && responseScriptFile.length) {
const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runResponseScript(
responseScriptFile,
request,
err.response,
envVariables,
collectionVariables,
collectionPath,
null,
processEnvVars
);
}
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(
assertions,
request,
err.response,
envVariables,
collectionVariables,
collectionPath
);
each(assertionResults, (r) => {
if (r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
} else {
console.log(chalk.red(``) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
console.log(chalk.red(` ${r.error}`));
}
});
}
// run tests
let testResults = [];
const testFile = get(bruJson, 'request.tests');
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const result = await testRuntime.runTests(
testFile,
request,
err.response,
envVariables,
collectionVariables,
collectionPath,
null,
processEnvVars
);
testResults = get(result, 'results', []);
}
if (testResults && testResults.length) {
each(testResults, (testResult) => {
if (testResult.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(testResult.description));
} else {
console.log(chalk.red(``) + chalk.red(testResult.description));
}
});
}
} else {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
}
} }
}; };

View File

@ -0,0 +1,40 @@
const axios = require('axios');
/**
* Function that configures axios with timing interceptors
* Important to note here that the timings are not completely accurate.
* @see https://github.com/axios/axios/issues/695
* @returns {import('axios').AxiosStatic}
*/
function makeAxiosInstance() {
/** @type {import('axios').AxiosStatic} */
const instance = axios.create();
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
return config;
});
instance.interceptors.response.use(
(response) => {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
return response;
},
(error) => {
if (error.response) {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
}
return Promise.reject(error);
}
);
return instance;
}
module.exports = {
makeAxiosInstance
};

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "v0.19.0", "version": "v0.20.0",
"name": "bruno", "name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs", "description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com", "homepage": "https://www.usebruno.com",
@ -21,6 +21,7 @@
"axios": "^1.5.1", "axios": "^1.5.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"decomment": "^0.9.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2", "electron-notarize": "^1.2.2",

View File

@ -103,7 +103,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data);
_.each(envSecrets, (secret) => { _.each(envSecrets, (secret) => {
const variable = _.find(file.data.variables, (v) => v.name === secret.name); const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable) { if (variable && secret.value) {
variable.value = decryptString(secret.value); variable.value = decryptString(secret.value);
} }
}); });
@ -137,7 +137,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data);
_.each(envSecrets, (secret) => { _.each(envSecrets, (secret) => {
const variable = _.find(file.data.variables, (v) => v.name === secret.name); const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable) { if (variable && secret.value) {
variable.value = decryptString(secret.value); variable.value = decryptString(secret.value);
} }
}); });

View File

@ -35,7 +35,10 @@ app.on('ready', async () => {
contextIsolation: true, contextIsolation: true,
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
webviewTag: true webviewTag: true
} },
title: 'Bruno',
icon: path.join(__dirname, 'about/256x256.png'),
autoHideMenuBar: true
}); });
const url = isDev const url = isDev

View File

@ -1,6 +1,7 @@
const qs = require('qs'); const qs = require('qs');
const https = require('https'); const https = require('https');
const axios = require('axios'); const axios = require('axios');
const decomment = require('decomment');
const Mustache = require('mustache'); const Mustache = require('mustache');
const FormData = require('form-data'); const FormData = require('form-data');
const { ipcMain } = require('electron'); const { ipcMain } = require('electron');
@ -153,7 +154,7 @@ const registerNetworkIpc = (mainWindow) => {
if (requestScript && requestScript.length) { if (requestScript && requestScript.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runRequestScript( const result = await scriptRuntime.runRequestScript(
requestScript, decomment(requestScript),
request, request,
envVars, envVars,
collectionVariables, collectionVariables,
@ -280,7 +281,7 @@ const registerNetworkIpc = (mainWindow) => {
if (responseScript && responseScript.length) { if (responseScript && responseScript.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runResponseScript( const result = await scriptRuntime.runResponseScript(
responseScript, decomment(responseScript),
request, request,
response, response,
envVars, envVars,
@ -326,7 +327,7 @@ const registerNetworkIpc = (mainWindow) => {
if (typeof testFile === 'string') { if (typeof testFile === 'string') {
const testRuntime = new TestRuntime(); const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, decomment(testFile),
request, request,
response, response,
envVars, envVars,
@ -405,7 +406,7 @@ const registerNetworkIpc = (mainWindow) => {
if (typeof testFile === 'string') { if (typeof testFile === 'string') {
const testRuntime = new TestRuntime(); const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, decomment(testFile),
request, request,
error.response, error.response,
envVars, envVars,
@ -461,10 +462,10 @@ const registerNetworkIpc = (mainWindow) => {
}); });
}); });
ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment) => { ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment, request, collectionVariables) => {
try { try {
const envVars = getEnvVars(environment); const envVars = getEnvVars(environment);
const request = prepareGqlIntrospectionRequest(endpoint, envVars); const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request);
const preferences = getPreferences(); const preferences = getPreferences();
const sslVerification = get(preferences, 'request.sslVerification', true); const sslVerification = get(preferences, 'request.sslVerification', true);
@ -475,7 +476,9 @@ const registerNetworkIpc = (mainWindow) => {
}); });
} }
const response = await axios(request); interpolateVars(preparedRequest, envVars, collectionVariables);
const response = await axios(preparedRequest);
return { return {
status: response.status, status: response.status,
@ -604,7 +607,7 @@ const registerNetworkIpc = (mainWindow) => {
if (requestScript && requestScript.length) { if (requestScript && requestScript.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runRequestScript( const result = await scriptRuntime.runRequestScript(
requestScript, decomment(requestScript),
request, request,
envVars, envVars,
collectionVariables, collectionVariables,
@ -705,7 +708,7 @@ const registerNetworkIpc = (mainWindow) => {
if (responseScript && responseScript.length) { if (responseScript && responseScript.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runResponseScript( const result = await scriptRuntime.runResponseScript(
responseScript, decomment(responseScript),
request, request,
response, response,
envVars, envVars,
@ -749,7 +752,7 @@ const registerNetworkIpc = (mainWindow) => {
if (typeof testFile === 'string') { if (typeof testFile === 'string') {
const testRuntime = new TestRuntime(); const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, decomment(testFile),
request, request,
response, response,
envVars, envVars,
@ -829,7 +832,7 @@ const registerNetworkIpc = (mainWindow) => {
if (typeof testFile === 'string') { if (typeof testFile === 'string') {
const testRuntime = new TestRuntime(); const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests( const testResults = await testRuntime.runTests(
testFile, decomment(testFile),
request, request,
error.response, error.response,
envVars, envVars,

View File

@ -1,12 +1,13 @@
const Mustache = require('mustache'); const Mustache = require('mustache');
const { getIntrospectionQuery } = require('graphql'); const { getIntrospectionQuery } = require('graphql');
const { get } = require('lodash');
// override the default escape function to prevent escaping // override the default escape function to prevent escaping
Mustache.escape = function (value) { Mustache.escape = function (value) {
return value; return value;
}; };
const prepareGqlIntrospectionRequest = (endpoint, envVars) => { const prepareGqlIntrospectionRequest = (endpoint, envVars, request) => {
if (endpoint && endpoint.length) { if (endpoint && endpoint.length) {
endpoint = Mustache.render(endpoint, envVars); endpoint = Mustache.render(endpoint, envVars);
} }
@ -15,7 +16,7 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars) => {
query: introspectionQuery query: introspectionQuery
}; };
const request = { let axiosRequest = {
method: 'POST', method: 'POST',
url: endpoint, url: endpoint,
headers: { headers: {
@ -25,7 +26,20 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars) => {
data: JSON.stringify(queryParams) data: JSON.stringify(queryParams)
}; };
return request; if (request.auth) {
if (request.auth.mode === 'basic') {
axiosRequest.auth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers.authorization = `Bearer ${get(request, 'auth.bearer.token')}`;
}
}
return axiosRequest;
}; };
module.exports = prepareGqlIntrospectionRequest; module.exports = prepareGqlIntrospectionRequest;

View File

@ -1,4 +1,5 @@
const { get, each, filter } = require('lodash'); const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const prepareRequest = (request) => { const prepareRequest = (request) => {
const headers = {}; const headers = {};
@ -37,7 +38,8 @@ const prepareRequest = (request) => {
axiosRequest.headers['content-type'] = 'application/json'; axiosRequest.headers['content-type'] = 'application/json';
} }
try { try {
axiosRequest.data = JSON.parse(request.body.json); // axiosRequest.data = JSON.parse(request.body.json);
axiosRequest.data = JSON.parse(decomment(request.body.json));
} catch (ex) { } catch (ex) {
axiosRequest.data = request.body.json; axiosRequest.data = request.body.json;
} }
@ -76,7 +78,7 @@ const prepareRequest = (request) => {
if (request.body.mode === 'graphql') { if (request.body.mode === 'graphql') {
const graphqlQuery = { const graphqlQuery = {
query: get(request, 'body.graphql.query'), query: get(request, 'body.graphql.query'),
variables: JSON.parse(get(request, 'body.graphql.variables') || '{}') variables: JSON.parse(decomment(get(request, 'body.graphql.variables') || '{}'))
}; };
if (!contentTypeDefined) { if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json'; axiosRequest.headers['content-type'] = 'application/json';

View File

@ -6,10 +6,12 @@
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) [![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/workflows/unit-tests.yml) [![CI](https://github.com/usebruno/bruno/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/workflows/unit-tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse) [![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![Twitter](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=twitter)](https://twitter.com/use_bruno) [![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) [![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads) [![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
**English** | [Русский](/readme_ru.md)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests. Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests.
@ -30,13 +32,19 @@ Or any version control system of your choice
![bruno](assets/images/version-control.png) <br /><br /> ![bruno](assets/images/version-control.png) <br /><br />
### Website 📄 ### Important Links 📌
Please visit [here](https://www.usebruno.com) to checkout our website and download the app - [Our Long Term Vision](https://github.com/usebruno/bruno/discussions/269)
- [Roadmap](https://github.com/usebruno/bruno/discussions/384)
- [Documentation](https://docs.usebruno.com)
- [Website](https://www.usebruno.com)
- [Download](https://www.usebruno.com/downloads)
### Documentation 📄 ### Showcase 🎥
Please visit [here](https://docs.usebruno.com) for documentation - [Testimonials](https://github.com/usebruno/bruno/discussions/343)
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
### Support ❤️ ### Support ❤️

79
readme_ru.md Normal file
View File

@ -0,0 +1,79 @@
<br />
<img src="assets/images/logo-transparent.png" width="80"/>
### Bruno - IDE с открытым исходным кодом для изучения и тестирования API.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/workflows/unit-tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](/readme.md) | **Русский**
Bruno - новый и инновационный клиент API, направленный на революцию в установившейся ситуации, представленной Postman и подобными инструментами.
Bruno хранит ваши коллекции непосредственно в папке в вашей файловой системе. Для сохранения информации об API-запросах мы используем язык Bru.
Для совместной работы над коллекциями API можно использовать git или любой другой контроль версий по вашему выбору.
Bruno работает только в автономном режиме. Добавление облачной синхронизации в Bruno не планируется. Мы ценим конфиденциальность ваших данных и считаем, что они должны оставаться на вашем устройстве. Ознакомьтесь с нашим долгосрочным видением [здесь](https://github.com/usebruno/bruno/discussions/269)
![bruno](assets/images/landing-2.png) <br /><br />
### Работа на нескольких платформах 🖥️
![bruno](assets/images/run-anywhere.png) <br /><br />
### Совместная работа через Git 👩‍💻🧑‍💻
Или другая система контроля версий по вашему выбору
![bruno](assets/images/version-control.png) <br /><br />
### Важные ссылки 📌
- [Наше долгосрочное видение](https://github.com/usebruno/bruno/discussions/269)
- [Roadmap](https://github.com/usebruno/bruno/discussions/384)
- [Документация](https://docs.usebruno.com)
- [Сайт](https://www.usebruno.com)
- [Скачать Bruno](https://www.usebruno.com/downloads)
### Витрина 🎥
- [Отзывы](https://github.com/usebruno/bruno/discussions/343)
- [Центр знаний](https://github.com/usebruno/bruno/discussions/386)
- [Скриптомания](https://github.com/usebruno/bruno/discussions/385)
### Поддержка ❤️
Гав! Если вам нравится проект, нажмите на звездочку ⭐ !!!
### Поделись отзывами 📣
Если Бруно помог вам в работе и в ваших командах, пожалуйста, не забудьте поделиться своим [отзывом на нашем обсуждении в github](https://github.com/usebruno/bruno/discussions/343)
### Внести вклад 👩‍💻🧑‍💻
Я рад, что Вы хотите улучшить Бруно. Пожалуйста, ознакомьтесь с [этим гайдом](contributing_ru.md)
Даже если вы не можете внести свой вклад с помощью кода, пожалуйста, не стесняйтесь сообщать об ошибках и пожеланиях к функциям, которые необходимо реализовать для решения вашей задачи.
### Авторы
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### Оставайтесь на связи 🌐
[X ( Twitter )](https://twitter.com/use_bruno) <br />
[Наш сайт](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq)
### Лицензия 📄
[MIT](license.md)