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 ; } + case 'auth': { + return ; + } case 'vars': { return ; } @@ -135,6 +146,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
selectTab('headers')}> Headers
+
selectTab('auth')}> + Auth +
selectTab('vars')}> Vars
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js index 7cfe7f95..e5912771 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/useGraphqlSchema.js @@ -6,7 +6,7 @@ import { simpleHash } from 'utils/common'; const schemaHashPrefix = 'bruno.graphqlSchema'; -const useGraphqlSchema = (endpoint, environment) => { +const useGraphqlSchema = (endpoint, environment, request, collectionVariables) => { const localStorageKey = `${schemaHashPrefix}.${simpleHash(endpoint)}`; const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -25,7 +25,7 @@ const useGraphqlSchema = (endpoint, environment) => { const loadSchema = () => { setIsLoading(true); - fetchGqlSchema(endpoint, environment) + fetchGqlSchema(endpoint, environment, request, collectionVariables) .then((res) => res.data) .then((s) => { if (s && s.data) { diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 9c8bbf4e..ad716437 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -105,7 +105,7 @@ const Sidebar = () => { Star -
v0.19.0
+
v0.20.0
diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js index 9768802f..041bf6e9 100644 --- a/packages/bruno-app/src/providers/App/index.js +++ b/packages/bruno-app/src/providers/App/index.js @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import useTelemetry from './useTelemetry'; import useCollectionTreeSync from './useCollectionTreeSync'; +import useCollectionNextAction from './useCollectionNextAction'; import { useDispatch } from 'react-redux'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; import StyledWrapper from './StyledWrapper'; @@ -10,6 +11,7 @@ export const AppContext = React.createContext(); export const AppProvider = (props) => { useTelemetry(); useCollectionTreeSync(); + useCollectionNextAction(); const dispatch = useDispatch(); diff --git a/packages/bruno-app/src/providers/App/useCollectionNextAction.js b/packages/bruno-app/src/providers/App/useCollectionNextAction.js new file mode 100644 index 00000000..94c58f60 --- /dev/null +++ b/packages/bruno-app/src/providers/App/useCollectionNextAction.js @@ -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; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index d806a383..c1ec2ff3 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -26,6 +26,7 @@ import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; import { updateLastAction, + updateNextAction, resetRunResults, requestCancelled, responseReceived, @@ -595,6 +596,19 @@ export const newHttpRequest = (params) => (dispatch, getState) => { const { ipcRenderer } = window; 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 { 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; 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 { return reject(new Error('Duplicate request names are not allowed under the same folder')); } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 2cb1bdea..31cf6d6c 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -39,6 +39,8 @@ export const collectionsSlice = createSlice({ createCollection: (state, action) => { const collectionUids = map(state.collections, (c) => c.uid); 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 // this is optional // 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.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); addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { @@ -93,6 +99,14 @@ export const collectionsSlice = createSlice({ 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) => { const { data: environment, meta } = action.payload; const collection = findCollectionByUid(state.collections, meta.collectionUid); @@ -1197,6 +1211,7 @@ export const { removeCollection, sortCollections, updateLastAction, + updateNextAction, collectionUnlinkEnvFileEvent, saveEnvironment, selectEnvironment, diff --git a/packages/bruno-app/src/utils/importers/insomnia-collection.js b/packages/bruno-app/src/utils/importers/insomnia-collection.js index b402e890..8b7b7fb6 100644 --- a/packages/bruno-app/src/utils/importers/insomnia-collection.js +++ b/packages/bruno-app/src/utils/importers/insomnia-collection.js @@ -41,6 +41,16 @@ const addSuffixToDuplicateName = (item, index, allItems) => { 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 name = addSuffixToDuplicateName(request, index, allRequests); @@ -51,6 +61,11 @@ const transformInsomniaRequestItem = (request, index, allRequests) => { request: { url: request.url, method: request.method, + auth: { + mode: 'none', + basic: null, + bearer: null + }, headers: [], params: [], 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') { brunoRequestItem.request.body.mode = 'json'; diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js index f4af7092..2c5c3a1d 100644 --- a/packages/bruno-app/src/utils/network/index.js +++ b/packages/bruno-app/src/utils/network/index.js @@ -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) => { 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); }); }; diff --git a/packages/bruno-cli/changelog.md b/packages/bruno-cli/changelog.md index 31b66ddc..1b83060f 100644 --- a/packages/bruno-cli/changelog.md +++ b/packages/bruno-cli/changelog.md @@ -1,5 +1,9 @@ # Changelog +## 0.12.0 + +- show response time in milliseconds per request and total + ## 0.11.0 - fix(#119) Support for Basic and Bearer Auth diff --git a/packages/bruno-cli/examples/report.json b/packages/bruno-cli/examples/report.json index 3dfa641f..4cb7586b 100644 --- a/packages/bruno-cli/examples/report.json +++ b/packages/bruno-cli/examples/report.json @@ -1,8 +1,11 @@ { "summary": { + "totalRequests": 10, + "passedRequests": 10, + "failedRequests": 0, "totalAssertions": 4, - "passedAssertions": 4, - "failedAssertions": 0, + "passedAssertions": 0, + "failedAssertions": 4, "totalTests": 0, "passedTests": 0, "failedTests": 0 @@ -11,53 +14,33 @@ { "request": { "method": "GET", - "url": "http://localhost:8080/test/v4", + "url": "http://localhost:3000/test/v4", "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-08gGpUcq2NTnMCVT5AuXxQ0DzGE\"", - "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/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": {} - } + "data": "\n\n\n\nError\n\n\n
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\nError\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\nError\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\nError\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\nError\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\nError\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\nError\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\nError\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": "\n 1\n" + }, + "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\nError\n\n\n
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)

-### 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 ❤️ diff --git a/readme_ru.md b/readme_ru.md new file mode 100644 index 00000000..8c25e5c5 --- /dev/null +++ b/readme_ru.md @@ -0,0 +1,79 @@ +
+ + +### 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)

+ +### Работа на нескольких платформах 🖥️ + +![bruno](assets/images/run-anywhere.png)

+ +### Совместная работа через Git 👩‍💻🧑‍💻 + +Или другая система контроля версий по вашему выбору + +![bruno](assets/images/version-control.png)

+ +### Важные ссылки 📌 + +- [Наше долгосрочное видение](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) + +Даже если вы не можете внести свой вклад с помощью кода, пожалуйста, не стесняйтесь сообщать об ошибках и пожеланиях к функциям, которые необходимо реализовать для решения вашей задачи. + +### Авторы + +
+ + + +
+ +### Оставайтесь на связи 🌐 + +[X ( Twitter )](https://twitter.com/use_bruno)
+[Наш сайт](https://www.usebruno.com)
+[Discord](https://discord.com/invite/KgcZUncpjq) + +### Лицензия 📄 + +[MIT](license.md)