diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 7c096e71..86a9e0eb 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -29,5 +29,7 @@ jobs:
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-cli
+ run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
diff --git a/contributing.md b/contributing.md
index a538f1bd..abfcce4d 100644
--- a/contributing.md
+++ b/contributing.md
@@ -1,3 +1,5 @@
+**English** | [Русский](/contributing_ru.md)
+
## 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.
diff --git a/contributing_ru.md b/contributing_ru.md
new file mode 100644
index 00000000..31640816
--- /dev/null
+++ b/contributing_ru.md
@@ -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
\ No newline at end of file
diff --git a/docs/development.md b/docs/development.md
index 77614d2f..c1c402e0 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -1,3 +1,5 @@
+**English** | [Русский](/docs/development_ru.md)
+
## 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.
diff --git a/docs/development_ru.md b/docs/development_ru.md
new file mode 100644
index 00000000..4d4e3a80
--- /dev/null
+++ b/docs/development_ru.md
@@ -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
+```
diff --git a/package-lock.json b/package-lock.json
index c609f0d8..c2229628 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6678,6 +6678,18 @@
"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": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@@ -7505,7 +7517,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
@@ -16765,7 +16776,7 @@
},
"packages/bruno-cli": {
"name": "@usebruno/cli",
- "version": "0.10.1",
+ "version": "0.11.0",
"license": "MIT",
"dependencies": {
"@usebruno/js": "0.6.0",
@@ -16773,6 +16784,7 @@
"axios": "^1.5.1",
"chai": "^4.3.7",
"chalk": "^3.0.0",
+ "decomment": "^0.9.5",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.8",
@@ -16810,7 +16822,7 @@
},
"packages/bruno-electron": {
"name": "bruno",
- "version": "v0.18.0",
+ "version": "v0.19.0",
"dependencies": {
"@usebruno/js": "0.6.0",
"@usebruno/lang": "0.5.0",
@@ -16819,6 +16831,7 @@
"axios": "^1.5.1",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
+ "decomment": "^0.9.5",
"dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2",
@@ -16848,7 +16861,9 @@
"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": {
"version": "16.18.11",
"dev": true,
@@ -20092,6 +20107,7 @@
"axios": "^1.5.1",
"chai": "^4.3.7",
"chalk": "^3.0.0",
+ "decomment": "*",
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.8",
@@ -21201,6 +21217,7 @@
"axios": "^1.5.1",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
+ "decomment": "^0.9.5",
"dmg-license": "^1.0.11",
"dotenv": "^16.0.3",
"electron": "21.1.1",
@@ -22309,6 +22326,14 @@
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"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": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@@ -22958,8 +22983,7 @@
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esrecurse": {
"version": "4.3.0",
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
index 773d8011..45a345a6 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
@@ -6,6 +6,7 @@ import { IconRefresh, IconLoader2, IconBook, IconDownload } from '@tabler/icons'
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryEditor from 'components/RequestPane/QueryEditor';
+import Auth from 'components/RequestPane/Auth';
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import Vars from 'components/RequestPane/Vars';
@@ -32,7 +33,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
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 = () => {
if (!isSchemaLoading) {
@@ -90,6 +98,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'headers': {
return
Cannot GET /test/v4\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "mTrKBl5YU6jiAVG-phKT4", + "uid": "oidgfXLiyD8Jv0NBAHUHF", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -65,53 +48,33 @@ { "request": { "method": "GET", - "url": "http://localhost:8080/test/v2", + "url": "http://localhost:3000/test/v2", "headers": {} }, "response": { - "status": 200, - "statusText": "OK", + "status": 404, + "statusText": "Not Found", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "497", - "etag": "W/\"1f1-lMqxZgVOJiQXjF5yk3AFEU8O9Ro\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "146", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "connection": "close" }, - "data": { - "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": {} - } + "data": "\n\n\n\n
Cannot GET /test/v2\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "XsjjGx9cjt5t8tE_t69ZB", + "uid": "IgliYuHd9wKp6JNyqyHFK", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -119,53 +82,33 @@ { "request": { "method": "GET", - "url": "http://localhost:8080/test/v3", + "url": "http://localhost:3000/test/v3", "headers": {} }, "response": { - "status": 200, - "statusText": "OK", + "status": 404, + "statusText": "Not Found", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "497", - "etag": "W/\"1f1-tSiYu0/vWz3r+NYRCaed0aW1waw\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-security-policy": "default-src 'none'", + "x-content-type-options": "nosniff", + "content-type": "text/html; charset=utf-8", + "content-length": "146", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "connection": "close" }, - "data": { - "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": {} - } + "data": "\n\n\n\n
Cannot GET /test/v3\n\n\n" }, + "error": null, "assertionResults": [ { - "uid": "i_8MmDMtJA9YfvB_FrW15", + "uid": "u-3sRebrCyuUbZOkwS0z8", "lhsExpr": "res.status", "rhsExpr": "200", "rhsOperand": "200", "operator": "eq", - "status": "pass" + "status": "fail", + "error": "expected 404 to equal 200" } ], "testResults": [] @@ -173,7 +116,7 @@ { "request": { "method": "POST", - "url": "http://localhost:8080/test/v1", + "url": "http://localhost:3000/test/v1", "headers": { "content-type": "application/json" }, @@ -181,57 +124,201 @@ "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": "\n\n\n\n
Cannot POST /test/v1\n\n\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": "\n\n\n\n
Cannot POST /test\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "HEAD", + "url": "http://localhost:3000/", + "headers": {} + }, "response": { "status": 200, "statusText": "OK", "headers": { "x-powered-by": "Express", - "content-type": "application/json; charset=utf-8", - "content-length": "623", - "etag": "W/\"26f-ku5QGz4p9f02u79vJIve7JH3QYM\"", - "date": "Mon, 25 Sep 2023 21:43:02 GMT", + "content-type": "text/html; charset=utf-8", + "content-length": "12", + "etag": "W/\"c-Lve95gjOVATpfV8EL5X4nxwjKHE\"", + "date": "Fri, 29 Sep 2023 00:37:50 GMT", "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": "\n\n\n\n
Cannot POST /\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/", + "headers": { + "content-type": "multipart/form-data; boundary=--------------------------897965859410704836065858" + }, "data": { - "path": "/test/v1", - "headers": { - "accept": "application/json, text/plain, */*", - "content-type": "application/json", - "user-agent": "axios/1.5.0", - "content-length": "16", - "accept-encoding": "gzip, compress, deflate, br", - "host": "localhost:8080", - "connection": "close" - }, - "method": "POST", - "body": "{\"test\":\"hello\"}", - "fresh": false, - "hostname": "localhost", - "ip": "", - "ips": [], - "protocol": "http", - "query": {}, - "subdomains": [], - "xhr": false, - "os": { - "hostname": "05512cb2102c" - }, - "connection": {}, - "json": { - "test": "hello" - } + "_overheadLength": 103, + "_valueLength": 3, + "_valuesToMeasure": [], + "writable": false, + "readable": true, + "dataSize": 0, + "maxDataSize": 2097152, + "pauseStreams": true, + "_released": true, + "_streams": [], + "_currentStream": null, + "_insideLoop": false, + "_pendingNext": false, + "_boundary": "--------------------------897965859410704836065858", + "_events": {}, + "_eventsCount": 3 } }, - "assertionResults": [ - { - "uid": "hNBSF_GBdSTFHNiyCcOn9", - "lhsExpr": "res.status", - "rhsExpr": "200", - "rhsOperand": "200", - "operator": "eq", - "status": "pass" - } - ], + "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": "\n\n\n\n
Cannot POST /\n\n\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": "\n\n\n\n
Cannot POST /\n\n\n" + }, + "error": null, + "assertionResults": [], + "testResults": [] + }, + { + "request": { + "method": "POST", + "url": "http://localhost:3000/test", + "headers": { + "content-type": "text/xml" + }, + "data": "
Cannot POST /test\n\n\n" + }, + "error": null, + "assertionResults": [], "testResults": [] } ] diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 014ecc29..c5e21350 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -1,6 +1,6 @@ { "name": "@usebruno/cli", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "main": "src/index.js", "bin": { @@ -13,6 +13,9 @@ "type": "git", "url": "git+https://github.com/usebruno/bruno.git" }, + "scripts": { + "test": "jest" + }, "files": [ "src", "bin", @@ -26,6 +29,7 @@ "axios": "^1.5.1", "chai": "^4.3.7", "chalk": "^3.0.0", + "decomment": "^0.9.5", "form-data": "^4.0.0", "fs-extra": "^10.1.0", "handlebars": "^4.7.8", diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 087b85d4..7866425e 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -12,17 +12,56 @@ const { dotenvToJson } = require('@usebruno/lang'); const command = 'run [filename]'; const desc = 'Run a request'; -const printRunSummary = (assertionResults, testResults) => { - // display assertion results and test results summary - const totalAssertions = assertionResults.length; - const passedAssertions = assertionResults.filter((result) => result.status === 'pass').length; - const failedAssertions = totalAssertions - passedAssertions; +const printRunSummary = (results) => { + let totalRequests = 0; + let passedRequests = 0; + let failedRequests = 0; + 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; + 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`)}`; if (failedTests > 0) { assertSummary += `, ${chalk.red(`${failedTests} failed`)}`; @@ -35,10 +74,14 @@ const printRunSummary = (assertionResults, testResults) => { } 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)); return { + totalRequests, + passedRequests, + failedRequests, totalAssertions, passedAssertions, failedAssertions, @@ -255,9 +298,7 @@ const handler = async function (argv) { } const _isFile = await isFile(filename); - let assertionResults = []; - let testResults = []; - let testrunResults = []; + let results = []; let bruJsons = []; @@ -311,17 +352,12 @@ const handler = async function (argv) { brunoConfig ); - if (result) { - testrunResults.push(result); - const { assertionResults: _assertionResults, testResults: _testResults } = result; - - assertionResults = assertionResults.concat(_assertionResults); - testResults = testResults.concat(_testResults); - } + results.push(result); } - const summary = printRunSummary(assertionResults, testResults); - console.log(chalk.dim(chalk.grey('Ran all requests.'))); + const summary = printRunSummary(results); + 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) { const outputDir = path.dirname(outputPath); @@ -333,14 +369,14 @@ const handler = async function (argv) { const outputJson = { summary, - results: testrunResults + results }; fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); 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); } } catch (err) { @@ -354,5 +390,6 @@ module.exports = { command, desc, builder, - handler + handler, + printRunSummary }; diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index e766d08e..e52cb541 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -1,4 +1,5 @@ const { get, each, filter } = require('lodash'); +const decomment = require('decomment'); const prepareRequest = (request) => { const headers = {}; @@ -39,7 +40,7 @@ const prepareRequest = (request) => { axiosRequest.headers['content-type'] = 'application/json'; } try { - axiosRequest.data = JSON.parse(request.body.json); + axiosRequest.data = JSON.parse(decomment(request.body.json)); } catch (ex) { axiosRequest.data = request.body.json; } @@ -78,7 +79,7 @@ const prepareRequest = (request) => { if (request.body.mode === 'graphql') { const graphqlQuery = { 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) { axiosRequest.headers['content-type'] = 'application/json'; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index aecdcbef..7291fe9b 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -1,9 +1,11 @@ const qs = require('qs'); const chalk = require('chalk'); +const decomment = require('decomment'); const fs = require('fs'); const { forOwn, each, extend, get } = require('lodash'); const FormData = require('form-data'); -const axios = require('axios'); +const axios = requir +e('axios'); const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); @@ -12,6 +14,7 @@ const { getOptions } = require('../utils/bru'); const https = require('https'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent'); +const { makeAxiosInstance } = require('../utils/axios-instance'); const runSingleRequest = async function ( filename, @@ -22,9 +25,9 @@ const runSingleRequest = async function ( processEnvVars, brunoConfig ) { - let request; - try { + let request; + request = prepareRequest(bruJson.request); // make axios work in node using form data @@ -57,7 +60,7 @@ const runSingleRequest = async function ( if (requestScriptFile && requestScriptFile.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runRequestScript( - requestScriptFile, + decomment(requestScriptFile), request, envVariables, collectionVariables, @@ -124,10 +127,48 @@ const runSingleRequest = async function ( request.data = qs.stringify(request.data); } - // run request - const response = await axios(request); + let response, responseTime; + 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 const postResponseVars = get(bruJson, 'request.vars.res'); @@ -149,7 +190,7 @@ const runSingleRequest = async function ( if (responseScriptFile && responseScriptFile.length) { const scriptRuntime = new ScriptRuntime(); await scriptRuntime.runResponseScript( - responseScriptFile, + decomment(responseScriptFile), request, response, envVariables, @@ -190,7 +231,7 @@ const runSingleRequest = async function ( if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const result = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVariables, @@ -223,102 +264,32 @@ const runSingleRequest = async function ( status: response.status, statusText: response.statusText, headers: response.headers, - data: response.data + data: response.data, + responseTime }, + error: null, assertionResults, testResults }; } catch (err) { - if (err && err.response) { - console.log( - chalk.green(stripExtension(filename)) + chalk.dim(` (${err.response.status} ${err.response.statusText})`) - ); - - // run post-response vars - const postResponseVars = get(bruJson, 'request.vars.res'); - if (postResponseVars && postResponseVars.length) { - const varsRuntime = new VarsRuntime(); - varsRuntime.runPostResponseVars( - postResponseVars, - request, - err.response, - envVariables, - collectionVariables, - collectionPath, - processEnvVars - ); - } - - // 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})`)); - } + return { + request: { + method: null, + url: null, + headers: null, + data: null + }, + response: { + status: null, + statusText: null, + headers: null, + data: null, + responseTime: 0 + }, + error: err.message, + assertionResults: [], + testResults: [] + }; } }; diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js new file mode 100644 index 00000000..f4810bec --- /dev/null +++ b/packages/bruno-cli/src/utils/axios-instance.js @@ -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 +}; diff --git a/packages/bruno-cli/tests/commands/run.spec.js b/packages/bruno-cli/tests/commands/run.spec.js new file mode 100644 index 00000000..10cdf42b --- /dev/null +++ b/packages/bruno-cli/tests/commands/run.spec.js @@ -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); + }); +}); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index b10e6786..8b1fce2a 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -1,5 +1,5 @@ { - "version": "v0.19.0", + "version": "v0.20.0", "name": "bruno", "description": "Opensource API Client for Exploring and Testing APIs", "homepage": "https://www.usebruno.com", @@ -21,6 +21,7 @@ "axios": "^1.5.1", "chai": "^4.3.7", "chokidar": "^3.5.3", + "decomment": "^0.9.5", "dotenv": "^16.0.3", "electron-is-dev": "^2.0.0", "electron-notarize": "^1.2.2", diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index c5973e79..b4162db9 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -103,7 +103,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); _.each(envSecrets, (secret) => { const variable = _.find(file.data.variables, (v) => v.name === secret.name); - if (variable) { + if (variable && 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); _.each(envSecrets, (secret) => { const variable = _.find(file.data.variables, (v) => v.name === secret.name); - if (variable) { + if (variable && secret.value) { variable.value = decryptString(secret.value); } }); diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index fb7fc45b..3908373e 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -35,7 +35,10 @@ app.on('ready', async () => { contextIsolation: true, preload: path.join(__dirname, 'preload.js'), webviewTag: true - } + }, + title: 'Bruno', + icon: path.join(__dirname, 'about/256x256.png'), + autoHideMenuBar: true }); const url = isDev diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 2295dbf3..9ba07b5e 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1,6 +1,7 @@ const qs = require('qs'); const https = require('https'); const axios = require('axios'); +const decomment = require('decomment'); const Mustache = require('mustache'); const FormData = require('form-data'); const { ipcMain } = require('electron'); @@ -153,7 +154,7 @@ const registerNetworkIpc = (mainWindow) => { if (requestScript && requestScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( - requestScript, + decomment(requestScript), request, envVars, collectionVariables, @@ -280,7 +281,7 @@ const registerNetworkIpc = (mainWindow) => { if (responseScript && responseScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( - responseScript, + decomment(responseScript), request, response, envVars, @@ -326,7 +327,7 @@ const registerNetworkIpc = (mainWindow) => { if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVars, @@ -405,7 +406,7 @@ const registerNetworkIpc = (mainWindow) => { if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, error.response, 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 { const envVars = getEnvVars(environment); - const request = prepareGqlIntrospectionRequest(endpoint, envVars); + const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request); const preferences = getPreferences(); 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 { status: response.status, @@ -604,7 +607,7 @@ const registerNetworkIpc = (mainWindow) => { if (requestScript && requestScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runRequestScript( - requestScript, + decomment(requestScript), request, envVars, collectionVariables, @@ -705,7 +708,7 @@ const registerNetworkIpc = (mainWindow) => { if (responseScript && responseScript.length) { const scriptRuntime = new ScriptRuntime(); const result = await scriptRuntime.runResponseScript( - responseScript, + decomment(responseScript), request, response, envVars, @@ -749,7 +752,7 @@ const registerNetworkIpc = (mainWindow) => { if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, response, envVars, @@ -829,7 +832,7 @@ const registerNetworkIpc = (mainWindow) => { if (typeof testFile === 'string') { const testRuntime = new TestRuntime(); const testResults = await testRuntime.runTests( - testFile, + decomment(testFile), request, error.response, envVars, diff --git a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js index a36666e3..d41be8f4 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js @@ -1,12 +1,13 @@ const Mustache = require('mustache'); const { getIntrospectionQuery } = require('graphql'); +const { get } = require('lodash'); // override the default escape function to prevent escaping Mustache.escape = function (value) { return value; }; -const prepareGqlIntrospectionRequest = (endpoint, envVars) => { +const prepareGqlIntrospectionRequest = (endpoint, envVars, request) => { if (endpoint && endpoint.length) { endpoint = Mustache.render(endpoint, envVars); } @@ -15,7 +16,7 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars) => { query: introspectionQuery }; - const request = { + let axiosRequest = { method: 'POST', url: endpoint, headers: { @@ -25,7 +26,20 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars) => { 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; diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 5a851291..922c9929 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -1,4 +1,5 @@ const { get, each, filter } = require('lodash'); +const decomment = require('decomment'); const prepareRequest = (request) => { const headers = {}; @@ -37,7 +38,8 @@ const prepareRequest = (request) => { axiosRequest.headers['content-type'] = 'application/json'; } 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) { axiosRequest.data = request.body.json; } @@ -76,7 +78,7 @@ const prepareRequest = (request) => { if (request.body.mode === 'graphql') { const graphqlQuery = { 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) { axiosRequest.headers['content-type'] = 'application/json'; diff --git a/readme.md b/readme.md index 82d70127..29cb5ad9 100644 --- a/readme.md +++ b/readme.md @@ -6,10 +6,12 @@ [![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) -[![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) [![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 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)