Merge branch 'main' into bugfix/fixes-251

This commit is contained in:
Jack Scotson 2023-10-08 10:52:18 +01:00
commit b47b0565e5
128 changed files with 33334 additions and 788 deletions

View File

@ -13,6 +13,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Check package-lock.json
run: npm ci
- name: Install dependencies
run: npm i --legacy-peer-deps
- name: Test Package bruno-query
@ -27,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

1
.gitignore vendored
View File

@ -4,7 +4,6 @@
node_modules
yarn.lock
pnpm-lock.yaml
package-lock.json
.pnp
.pnp.js

View File

@ -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.
@ -19,7 +21,7 @@ Libraries we use
### Dependencies
You would need [Node v14.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
You would need [Node v18.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
### Lets start coding

37
contributing_ru.md Normal file
View File

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

View File

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

55
docs/development_ru.md Normal file
View File

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

29702
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,5 @@
},
"overrides": {
"rollup": "3.2.5"
},
"dependencies": {}
}
}

View File

@ -30,6 +30,8 @@
"graphiql": "^1.5.9",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"handlebars": "^4.7.8",
"httpsnippet": "^3.0.1",
"idb": "^7.0.0",
"immer": "^9.0.15",
"know-your-http-well": "^0.5.0",

View File

@ -119,7 +119,7 @@ export default class CodeEditor extends React.Component {
render() {
return (
<StyledWrapper
className="h-full"
className="h-full w-full"
aria-label="Code Editor"
ref={(node) => {
this._node = node;

View File

@ -0,0 +1,75 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ collection, environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyEnvironment(values.name, environment.uid, collection.uid))
.then(() => {
toast.success('Environment created in collection');
onClose();
})
.catch(() => toast.error('An error occurred while created the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title={'Copy Environment'} confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="name" className="block font-semibold">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@ -17,7 +17,7 @@ const CreateEnvironment = ({ collection, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),

View File

@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { IconEdit, IconTrash, IconDatabase } from '@tabler/icons';
import EnvironmentVariables from './EnvironmentVariables';
import RenameEnvironment from '../../RenameEnvironment';
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, collection }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
@ -20,6 +22,9 @@ const EnvironmentDetails = ({ environment, collection }) => {
collection={collection}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
@ -27,6 +32,7 @@ const EnvironmentDetails = ({ environment, collection }) => {
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
</div>
</div>

View File

@ -17,7 +17,7 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),

View File

@ -1,6 +1,8 @@
import styled from 'styled-components';
const Wrapper = styled.div`
color: ${(props) => props.theme.text};
&.modal--animate-out {
animation: fade-out 0.5s forwards cubic-bezier(0.19, 1, 0.22, 1);
@ -20,6 +22,7 @@ const Wrapper = styled.div`
justify-content: center;
overflow-y: auto;
z-index: 10;
background-color: rgba(0, 0, 0, 0.5);
}
.bruno-modal-card {

View File

@ -61,19 +61,20 @@ const Modal = ({
children,
confirmDisabled,
hideCancel,
hideFooter
hideFooter,
closeModalFadeTimeout = 500
}) => {
const [isClosing, setIsClosing] = useState(false);
const escFunction = (event) => {
const escKeyCode = 27;
if (event.keyCode === escKeyCode) {
closeModal();
closeModal({ type: 'esc' });
}
};
const closeModal = () => {
const closeModal = (args) => {
setIsClosing(true);
setTimeout(() => handleCancel(), 500);
setTimeout(() => handleCancel(args), closeModalFadeTimeout);
};
useEffect(() => {
@ -94,12 +95,12 @@ const Modal = ({
return (
<StyledWrapper className={classes}>
<div className={`bruno-modal-card modal-${size}`}>
<ModalHeader title={title} handleCancel={() => closeModal()} />
<ModalHeader title={title} handleCancel={() => closeModal({ type: 'icon' })} />
<ModalContent>{children}</ModalContent>
<ModalFooter
confirmText={confirmText}
cancelText={cancelText}
handleCancel={() => closeModal()}
handleCancel={() => closeModal({ type: 'button' })}
handleSubmit={handleConfirm}
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
@ -108,7 +109,12 @@ const Modal = ({
</div>
{/* Clicking on backdrop closes the modal */}
<div className="bruno-modal-backdrop" onClick={() => closeModal()} />
<div
className="bruno-modal-backdrop"
onClick={() => {
closeModal({ type: 'backdrop' });
}}
/>
</StyledWrapper>
);
};

View File

@ -197,10 +197,11 @@ const AssertionRow = ({
<input
type="checkbox"
checked={assertion.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleAssertionChange(e, assertion, 'enabled')}
/>
<button onClick={() => handleRemoveAssertion(assertion)}>
<button tabIndex="-1" onClick={() => handleRemoveAssertion(assertion)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -0,0 +1,28 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.8125rem;
.auth-mode-selector {
background: transparent;
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@ -0,0 +1,70 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const AuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
mode: value
})
);
};
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default AuthMode;

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,76 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUsernameChange = (username) => {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: basicAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'basic',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: basicAuth.username,
password: password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={basicAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUsernameChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default BasicAuth;

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,51 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token')
: get(item, 'request.auth.bearer.token');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleTokenChange = (token) => {
dispatch(
updateAuth({
mode: 'bearer',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
token: token
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleTokenChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default BearerAuth;

View File

@ -0,0 +1,5 @@
import styled from 'styled-components';
const Wrapper = styled.div``;
export default Wrapper;

View File

@ -0,0 +1,31 @@
import React from 'react';
import get from 'lodash/get';
import AuthMode from './AuthMode';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import StyledWrapper from './StyledWrapper';
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
}
}
};
return (
<StyledWrapper className="w-full mt-1">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);
};
export default Auth;

View File

@ -116,10 +116,11 @@ const FormUrlEncodedParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParams(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -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);
const loadGqlSchema = () => {
if (!isSchemaLoading) {
@ -90,6 +98,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
@ -135,6 +146,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>

View File

@ -6,7 +6,7 @@ import { simpleHash } from 'utils/common';
const schemaHashPrefix = 'bruno.graphqlSchema';
const useGraphqlSchema = (endpoint, environment) => {
const useGraphqlSchema = (endpoint, environment, request, collection) => {
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, collection)
.then((res) => res.data)
.then((s) => {
if (s && s.data) {

View File

@ -7,6 +7,8 @@ import QueryParams from 'components/RequestPane/QueryParams';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
import AuthMode from 'components/RequestPane/Auth/AuthMode';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@ -38,6 +40,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'headers': {
return <RequestHeaders item={item} collection={collection} />;
}
case 'auth': {
return <Auth item={item} collection={collection} />;
}
case 'vars': {
return <Vars item={item} collection={collection} />;
}
@ -83,6 +88,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
</div>
@ -95,15 +103,15 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
</div>
{/* Moved to post mvp */}
{/* <div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>Auth</div> */}
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
<RequestBodyMode item={item} collection={collection} />
</div>
) : null}
</div>
<section className={`flex w-full ${['script', 'vars'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'}`}>
<section
className={`flex w-full ${['script', 'vars', 'auth'].includes(focusedTab.requestPaneTab) ? '' : 'mt-5'}`}
>
{getTabPanel(focusedTab.requestPaneTab)}
</section>
</StyledWrapper>

View File

@ -116,10 +116,11 @@ const MultipartFormParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParams(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParams(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -115,10 +115,11 @@ const QueryParams = ({ item, collection }) => {
<input
type="checkbox"
checked={param.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
<button onClick={() => handleRemoveParam(param)}>
<button tabIndex="-1" onClick={() => handleRemoveParam(param)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -32,6 +32,50 @@ const Wrapper = styled.div`
position: relative;
top: 1px;
}
.tooltip {
position: relative;
display: inline-block;
cursor: pointer;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
.tooltiptext {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
color: ${(props) => props.theme.text};
text-align: center;
border-radius: 4px;
padding: 4px 8px;
position: absolute;
z-index: 1;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
.tooltiptext::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
}
.shortcut {
font-size: 0.625rem;
}
`;
export default Wrapper;

View File

@ -5,8 +5,9 @@ import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/sli
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import SendIcon from 'components/Icons/Send';
import { IconDeviceFloppy, IconArrowRight } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper';
const QueryUrl = ({ item, collection, handleRun }) => {
@ -14,6 +15,8 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
const url = item.draft ? get(item, 'draft.request.url') : get(item, 'request.url');
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
@ -22,7 +25,10 @@ const QueryUrl = ({ item, collection, handleRun }) => {
setMethodSelectorWidth(el.offsetWidth);
}, [method]);
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onSave = () => {
dispatch(saveRequest(item.uid, collection.uid));
};
const onUrlChange = (value) => {
dispatch(
requestUrlChanged({
@ -65,7 +71,25 @@ const QueryUrl = ({ item, collection, handleRun }) => {
collection={collection}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<SendIcon color={theme.requestTabPanel.url.icon} width={22} />
<div
className="tooltip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="tooltiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
</div>
</div>
</StyledWrapper>

View File

@ -123,10 +123,11 @@ const RequestHeaders = ({ item, collection }) => {
<input
type="checkbox"
checked={header.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
/>
<button onClick={() => handleRemoveHeader(header)}>
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -128,10 +128,11 @@ const VarsTable = ({ item, collection, vars, varType }) => {
<input
type="checkbox"
checked={_var.enabled}
tabIndex="-1"
className="mr-3 mousetrap"
onChange={(e) => handleVarChange(e, _var, 'enabled')}
/>
<button onClick={() => handleRemoveVar(_var)}>
<button tabIndex="-1" onClick={() => handleRemoveVar(_var)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</div>

View File

@ -0,0 +1,30 @@
import React from 'react';
import Modal from 'components/Modal';
const ConfirmRequestClose = ({ onCancel, onCloseWithoutSave, onSaveAndClose }) => {
const _handleCancel = ({ type }) => {
if (type === 'button') {
return onCloseWithoutSave();
}
return onCancel();
};
return (
<Modal
size="sm"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
handleConfirm={onSaveAndClose}
handleCancel={_handleCancel}
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
>
<div className="font-normal">You have unsaved changes in you request.</div>
</Modal>
);
};
export default ConfirmRequestClose;

View File

@ -1,14 +1,22 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import { findItemInCollection } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import RequestTabNotFound from './RequestTabNotFound';
import ConfirmRequestClose from './ConfirmRequestClose';
import SpecialTab from './SpecialTab';
import { useTheme } from 'providers/Theme';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
const RequestTab = ({ tab, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false);
const handleCloseClick = (event) => {
event.stopPropagation();
@ -21,35 +29,38 @@ const RequestTab = ({ tab, collection }) => {
};
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
let color = '';
method = method.toLocaleLowerCase();
switch (method) {
case 'get': {
color = 'var(--color-method-get)';
color = theme.request.methods.get;
break;
}
case 'post': {
color = 'var(--color-method-post)';
color = theme.request.methods.post;
break;
}
case 'put': {
color = 'var(--color-method-put)';
color = theme.request.methods.put;
break;
}
case 'delete': {
color = 'var(--color-method-delete)';
color = theme.request.methods.delete;
break;
}
case 'patch': {
color = 'var(--color-method-patch)';
color = theme.request.methods.patch;
break;
}
case 'options': {
color = 'var(--color-method-options)';
color = theme.request.methods.options;
break;
}
case 'head': {
color = 'var(--color-method-head)';
color = theme.request.methods.head;
break;
}
}
@ -79,6 +90,39 @@ const RequestTab = ({ tab, collection }) => {
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{showConfirmClose && (
<ConfirmRequestClose
onCancel={() => setShowConfirmClose(false)}
onCloseWithoutSave={() => {
dispatch(
deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
})
);
dispatch(
closeTabs({
tabUids: [tab.uid]
})
);
setShowConfirmClose(false);
}}
onSaveAndClose={() => {
dispatch(saveRequest(item.uid, collection.uid))
.then(() => {
dispatch(
closeTabs({
tabUids: [tab.uid]
})
);
setShowConfirmClose(false);
})
.catch((err) => {
console.log('err', err);
});
}}
/>
)}
<div className="flex items-baseline tab-label pl-2">
<span className="tab-method uppercase" style={{ color: getMethodColor(method), fontSize: 12 }}>
{method}
@ -87,7 +131,14 @@ const RequestTab = ({ tab, collection }) => {
{item.name}
</span>
</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!item.draft) return handleCloseClick(e);
setShowConfirmClose(true);
}}
>
{!item.draft ? (
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon">
<path

View File

@ -1,6 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: absolute;
z-index: 1;
height: 100vh;
background-color: ${(props) => props.theme.requestTabPanel.responseOverlayBg};
div.overlay {
position: absolute;
top: 0;
@ -14,6 +19,11 @@ const StyledWrapper = styled.div`
padding-top: 20%;
overflow: hidden;
text-align: center;
.loading-icon {
transform: scaleY(-1);
animation: rotateCounterClockwise 1s linear infinite;
}
}
`;

View File

@ -13,17 +13,17 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
};
return (
<StyledWrapper className="mt-4 px-3 w-full">
<StyledWrapper className="px-3 w-full">
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 24, marginLeft: 5, marginRight: 5 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
<StopWatch />
</div>
</div>
<IconRefresh size={24} className="animate-spin" />
<IconRefresh size={24} className="loading-icon" />
<button
onClick={handleCancelRequest}
className="mt-4 uppercase btn-md rounded btn-secondary ease-linear transition-all duration-150"
className="mt-4 uppercase btn-sm rounded btn-secondary ease-linear transition-all duration-150"
type="button"
>
Cancel Request

View File

@ -13,13 +13,11 @@ const Placeholder = () => {
<div className="px-1 py-2">Send Request</div>
<div className="px-1 py-2">New Request</div>
<div className="px-1 py-2">Edit Environments</div>
<div className="px-1 py-2">Help</div>
</div>
<div className="flex flex-1 flex-col px-1">
<div className="px-1 py-2">Cmd + Enter</div>
<div className="px-1 py-2">Cmd + B</div>
<div className="px-1 py-2">Cmd + E</div>
<div className="px-1 py-2">Cmd + H</div>
</div>
</div>
</StyledWrapper>

View File

@ -1,9 +1,27 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: grid;
grid-template-columns: 100%;
grid-template-rows: 1.25rem calc(100% - 1.25rem);
/* This is a hack to force Codemirror to use all available space */
> div {
position: relative;
}
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 220px);
position: absolute;
top: 0;
bottom: 0;
height: 100%;
width: 100%;
}
div[role='tablist'] {
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;

View File

@ -3,12 +3,57 @@ import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import classnames from 'classnames';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
import { useState } from 'react';
import { useMemo } from 'react';
const QueryResult = ({ item, collection, value, width, disableRunEventListener, mode }) => {
const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers }) => {
const { storedTheme } = useTheme();
const [tab, setTab] = useState('preview');
const dispatch = useDispatch();
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType);
const formatResponse = (data, mode) => {
if (!data) {
return '';
}
if (mode.includes('json')) {
return safeStringifyJSON(data, true);
}
if (mode.includes('xml')) {
let parsed = safeParseXML(data, { collapseContent: true });
if (typeof parsed === 'string') {
return parsed;
}
return safeStringifyJSON(parsed, true);
}
if (['text', 'html'].includes(mode)) {
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
}
// final fallback
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
};
const value = formatResponse(data, mode);
const onRun = () => {
if (disableRunEventListener) {
@ -17,18 +62,52 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
dispatch(sendRequest(item, collection.uid));
};
return (
<StyledWrapper className="px-3 w-full" style={{ maxWidth: width }}>
<div className="h-full">
<CodeEditor
collection={collection}
theme={storedTheme}
onRun={onRun}
value={value || ''}
mode={mode}
readOnly
const getTabClassname = (tabName) => {
return classnames(`select-none ${tabName}`, {
active: tabName === tab,
'cursor-pointer': tabName !== tab
});
};
const getTabs = () => {
if (!mode.includes('html')) {
return null;
}
return (
<>
<div className={getTabClassname('raw')} role="tab" onClick={() => setTab('raw')}>
Raw
</div>
<div className={getTabClassname('preview')} role="tab" onClick={() => setTab('preview')}>
Preview
</div>
</>
);
};
const activeResult = useMemo(() => {
if (tab === 'preview' && mode.includes('html') && item.requestSent && item.requestSent.url) {
// Add the Base tag to the head so content loads properly. This also needs the correct CSP settings
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent.url}">`);
return (
<webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
webpreferences="disableDialogs=true, javascript=yes"
className="h-full bg-white"
/>
);
}
return <CodeEditor collection={collection} theme={storedTheme} onRun={onRun} value={value} mode={mode} readOnly />;
}, [tab, collection, storedTheme, onRun, value, mode]);
return (
<StyledWrapper className="px-3 w-full h-full" style={{ maxWidth: width }}>
<div className="flex justify-end gap-2 text-xs" role="tablist">
{getTabs()}
</div>
{activeResult}
</StyledWrapper>
);
};

View File

@ -7,7 +7,7 @@ const ResponseSize = ({ size }) => {
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = ((size % 1024) / 1024).toFixed(2) * 100;
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
sizeToDisplay = size + 'B';

View File

@ -16,7 +16,7 @@ const TestResults = ({ results, assertionResults }) => {
return (
<StyledWrapper className="flex flex-col px-3">
<div className="py-2 font-medium test-summary">
<div className="pb-2 font-medium test-summary">
Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
</div>
<ul className="">

View File

@ -2,7 +2,6 @@ import React from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { getContentType, formatResponse } from 'utils/common';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryResult from './QueryResult';
import Overlay from './Overlay';
@ -41,8 +40,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
item={item}
collection={collection}
width={rightPaneWidth}
value={response.data ? formatResponse(response) : ''}
mode={getContentType(response.headers)}
data={response.data}
headers={response.headers}
/>
);
}
@ -62,15 +61,15 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
}
};
if (isLoading) {
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex h-full relative">
<StyledWrapper className="flex flex-col h-full relative">
<Overlay item={item} collection={collection} />
</StyledWrapper>
);
}
if (response.state !== 'success') {
if (!item.response) {
return (
<StyledWrapper className="flex h-full relative">
<Placeholder />
@ -116,7 +115,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
</div>
) : null}
</div>
<section className="flex flex-grow mt-5">{getTabPanel(focusedTab.responsePaneTab)}</section>
<section className={`flex flex-grow ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{getTabPanel(focusedTab.responsePaneTab)}
</section>
</StyledWrapper>
);
};

View File

@ -33,7 +33,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
collection={collection}
width={rightPaneWidth}
disableRunEventListener={true}
value={responseReceived && responseReceived.data ? safeStringifyJSON(responseReceived.data, true) : ''}
data={responseReceived.data}
headers={responseReceived.headers}
/>
);
}

View File

@ -18,7 +18,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),

View File

@ -0,0 +1,21 @@
import CodeEditor from 'components/CodeEditor/index';
import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import { buildHarRequest } from 'utils/codegenerator/har';
const CodeView = ({ language, item }) => {
const { storedTheme } = useTheme();
const { target, client, language: lang } = language;
let snippet = '';
try {
snippet = new HTTPSnippet(buildHarRequest(item.request)).convert(target, client);
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
}
return <CodeEditor readOnly value={snippet} theme={storedTheme} mode={lang} />;
};
export default CodeView;

View File

@ -0,0 +1,38 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.generate-code-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
}
.generate-code-item {
min-width: 150px;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,149 @@
import Modal from 'components/Modal/index';
import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index';
import get from 'lodash/get';
import handlebars from 'handlebars';
import { findEnvironmentInCollection } from 'utils/collections';
const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) => {
if (!url || !url.length || typeof url !== 'string') {
return;
}
const template = handlebars.compile(url, { noEscape: true });
return template({
...envVars,
...collectionVariables,
process: {
env: {
...processEnvVars
}
}
});
};
const languages = [
{
name: 'HTTP',
target: 'http',
client: 'http1.1'
},
{
name: 'JavaScript-Fetch',
target: 'javascript',
client: 'fetch'
},
{
name: 'Javascript-jQuery',
target: 'javascript',
client: 'jquery'
},
{
name: 'Javascript-axios',
target: 'javascript',
client: 'axios'
},
{
name: 'Python-Python3',
target: 'python',
client: 'python3'
},
{
name: 'Python-Requests',
target: 'python',
client: 'requests'
},
{
name: 'PHP',
target: 'php',
client: 'curl'
},
{
name: 'Shell-curl',
target: 'shell',
client: 'curl'
},
{
name: 'Shell-httpie',
target: 'shell',
client: 'httpie'
}
];
const GenerateCodeItem = ({ collection, item, onClose }) => {
const url = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
envVars = vars.reduce((acc, curr) => {
acc[curr.name] = curr.value;
return acc;
}, {});
}
const interpolatedUrl = interpolateUrl({
url,
envVars,
collectionVariables: collection.collectionVariables,
processEnvVars: collection.processEnvVariables
});
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full">
<div>
<div className="generate-code-sidebar">
{languages &&
languages.length &&
languages.map((language) => (
<div
key={language.name}
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
onClick={() => setSelectedLanguage(language)}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
{isValidUrl(interpolatedUrl) ? (
<CodeView
language={selectedLanguage}
item={{
...item,
request:
item.request.url !== ''
? {
...item.request,
url: interpolatedUrl
}
: {
...item.draft.request,
url: interpolatedUrl
}
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {interpolatedUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
</Modal>
);
};
export default GenerateCodeItem;

View File

@ -17,7 +17,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),

View File

@ -25,13 +25,13 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.request.methods.delete};
}
.method-patch {
color: ${(props) => props.theme.request.methods.put};
color: ${(props) => props.theme.request.methods.patch};
}
.method-options {
color: ${(props) => props.theme.request.methods.put};
color: ${(props) => props.theme.request.methods.options};
}
.method-head {
color: ${(props) => props.theme.request.methods.put};
color: ${(props) => props.theme.request.methods.head};
}
`;

View File

@ -15,7 +15,8 @@ const RequestMethod = ({ item }) => {
'method-put': method === 'put',
'method-delete': method === 'delete',
'method-patch': method === 'patch',
'method-head': method === 'head'
'method-head': method === 'head',
'method-options': method == 'options'
});
};

View File

@ -16,11 +16,12 @@ import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const CollectionItem = ({ item, collection, searchText }) => {
@ -32,6 +33,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
@ -113,6 +115,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
const handleDoubleClick = (event) => {
setRenameItemModalOpen(true);
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
@ -142,7 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
@ -166,6 +180,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className="flex items-center h-full w-full">
{indents && indents.length
@ -173,6 +190,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
return (
<div
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
@ -188,6 +206,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
: null}
<div
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
@ -264,6 +283,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
Clone
</div>
)}
{!isFolder && item.type === 'http-request' && (
<div
className="dropdown-item"
onClick={(e) => {
handleGenerateCode(e);
}}
>
Generate Code
</div>
)}
<div
className="dropdown-item delete-item"
onClick={(e) => {

View File

@ -16,7 +16,7 @@ const RenameCollection = ({ collection, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),

View File

@ -16,7 +16,7 @@ import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import CollectionProperties from './CollectionProperties';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest, transformCollectionToSaveToIdb } from 'utils/collections';
import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections';
import exportCollection from 'utils/collections/export';
import RenameCollection from './RenameCollection';
@ -69,7 +69,7 @@ const Collection = ({ collection, searchText }) => {
const handleExportClick = () => {
const collectionCopy = cloneDeep(collection);
exportCollection(transformCollectionToSaveToIdb(collectionCopy));
exportCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
};
const [{ isOver }, drop] = useDrop({

View File

@ -1,21 +1,61 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { IconSearch, IconFolders } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import {
IconSearch,
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
IconSortDescendingLetters
} from '@tabler/icons';
import Collection from '../Collections/Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
// the coding convention is to keep all the components in a folder named after the component
const CollectionsBadge = () => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
return (
<div className="items-center mt-2 relative">
<div className="collections-badge flex items-center pl-2 pr-2 py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
<div className="collections-badge flex items-center justify-between px-2">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
</div>
{collections.length >= 1 && (
<button onClick={() => sortCollectionOrder()}>
{collectionSortOrder == 'default' ? (
<IconArrowsSort size={18} strokeWidth={1.5} />
) : collectionSortOrder == 'alphabetical' ? (
<IconSortAscendingLetters size={18} strokeWidth={1.5} />
) : (
<IconSortDescendingLetters size={18} strokeWidth={1.5} />
)}
</button>
)}
</div>
</div>
);

View File

@ -21,17 +21,15 @@ const CreateCollection = ({ onClose }) => {
},
validationSchema: Yup.object({
collectionName: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.required('folder name is required'),
collectionLocation: Yup.string()
.min(1, 'location is required')
.required('location is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
onSubmit: (values) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
@ -116,7 +114,10 @@ const CreateCollection = ({ onClose }) => {
<label htmlFor="collection-folder-name" className="flex items-center mt-3">
<span className="font-semibold">Folder Name</span>
<Tooltip text="This folder will be created under the selected location" tooltipId="collection-folder-name-tooltip" />
<Tooltip
text="This folder will be created under the selected location"
tooltipId="collection-folder-name-tooltip"
/>
</label>
<input
id="collection-folder-name"

View File

@ -16,7 +16,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
},
validationSchema: Yup.object({
collectionLocation: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
.required('name is required')
}),

View File

@ -16,7 +16,7 @@ const NewFolder = ({ collection, item, onClose }) => {
},
validationSchema: Yup.object({
folderName: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.required('name is required')
.test({
name: 'folderName',

View File

@ -25,7 +25,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
},
validationSchema: Yup.object({
requestName: Yup.string()
.min(1, 'must be atleast 1 characters')
.min(1, 'must be at least 1 character')
.required('name is required')
.test({
name: 'requestName',

View File

@ -18,6 +18,7 @@ const TitleBar = () => {
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const dispatch = useDispatch();
const { ipcRenderer } = window;
const handleImportCollection = (collection) => {
setImportedCollection(collection);
@ -50,6 +51,10 @@ const TitleBar = () => {
);
};
const openDevTools = () => {
ipcRenderer.invoke('renderer:open-devtools');
};
return (
<StyledWrapper className="px-2 py-2">
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
@ -104,6 +109,15 @@ const TitleBar = () => {
>
Import Collection
</div>
<div
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
openDevTools();
}}
>
Devtools
</div>
</Dropdown>
</div>
</div>

View File

@ -96,27 +96,16 @@ const Sidebar = () => {
/>
</div>
<div className="pl-1" style={{ position: 'relative', top: '3px' }}>
{storedTheme === 'dark' ? (
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme="no-preference: dark; light: dark; dark: light;"
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
) : (
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme="no-preference: light; light: light; dark: light;"
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
)}
<GitHubButton
href="https://github.com/usebruno/bruno"
data-color-scheme={storedTheme}
data-show-count="true"
aria-label="Star usebruno/bruno on GitHub"
>
Star
</GitHubButton>
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.16.5</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.21.1</div>
</div>
</div>
</div>

View File

@ -66,6 +66,7 @@ class SingleLineEditor extends Component {
variables: getAllVariables(this.props.collection)
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
@ -104,7 +105,9 @@ class SingleLineEditor extends Component {
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
Tab: () => {}
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
}
});
if (this.props.autocomplete) {

View File

@ -129,6 +129,24 @@ const GlobalStyle = createGlobalStyle`
}
}
@keyframes rotateClockwise {
0% {
transform: scaleY(-1) rotate(0deg);
}
100% {
transform: scaleY(-1) rotate(360deg);
}
}
@keyframes rotateCounterClockwise {
0% {
transform: scaleY(-1) rotate(360deg);
}
100% {
transform: scaleY(-1) rotate(0deg);
}
}
// codemirror
.CodeMirror {
.cm-variable-valid {

View File

@ -15,6 +15,7 @@ import 'tailwindcss/dist/tailwind.min.css';
import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
import 'react-tooltip/dist/react-tooltip.css';
import '@usebruno/graphql-docs/dist/esm/index.css';
function SafeHydrate({ children }) {
return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>;

View File

@ -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();

View File

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

View File

@ -7,7 +7,6 @@ import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import BrunoSupport from 'components/BrunoSupport';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
@ -22,7 +21,6 @@ export const HotkeysProvider = (props) => {
const [showSaveRequestModal, setShowSaveRequestModal] = useState(false);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showBrunoSupportModal, setShowBrunoSupportModal] = useState(false);
const getCurrentCollectionItems = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
@ -133,18 +131,6 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// help (ctrl/cmd + h)
useEffect(() => {
Mousetrap.bind(['command+h', 'ctrl+h'], (e) => {
setShowBrunoSupportModal(true);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+h', 'ctrl+h']);
};
}, [setShowNewRequestModal]);
// close tab hotkey
useEffect(() => {
Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
@ -164,7 +150,6 @@ export const HotkeysProvider = (props) => {
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showBrunoSupportModal && <BrunoSupport onClose={() => setShowBrunoSupportModal(false)} />}
{showSaveRequestModal && (
<SaveRequest items={getCurrentCollectionItems()} onClose={() => setShowSaveRequestModal(false)} />
)}

View File

@ -12,7 +12,6 @@ import {
getItemsToResequence,
moveCollectionItemToRootOfCollection,
findCollectionByUid,
recursivelyGetAllItemUids,
transformRequestToSaveToFilesystem,
findParentItemInCollection,
findEnvironmentInCollection,
@ -22,11 +21,12 @@ import {
} from 'utils/collections';
import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common';
import { getDirectoryName } from 'utils/common/platform';
import { getDirectoryName, isWindowsOS } from 'utils/common/platform';
import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import {
updateLastAction,
updateNextAction,
resetRunResults,
requestCancelled,
responseReceived,
@ -39,6 +39,7 @@ import {
createCollection as _createCollection,
renameCollection as _renameCollection,
removeCollection as _removeCollection,
sortCollections as _sortCollections,
collectionAddEnvFileEvent as _collectionAddEnvFileEvent
} from './index';
@ -81,8 +82,12 @@ export const saveRequest = (itemUid, collectionUid) => (dispatch, getState) => {
itemSchema
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:save-request', item.pathname, itemToSave))
.then(() => toast.success('Request saved successfully'))
.then(resolve)
.catch(reject);
.catch((err) => {
toast.error('Failed to save request!');
reject(err);
});
});
};
@ -145,6 +150,11 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) =>
.catch((err) => console.log(err));
};
// todo: this can be directly put inside the collections/index.js file
// the coding convention is to put only actions that need ipc in this file
export const sortCollections = (order) => (dispatch) => {
dispatch(_sortCollections(order));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -262,7 +272,19 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject);
ipcRenderer
.invoke('renderer:rename-item', item.pathname, newPathname, newName)
.then(() => {
// In case of Mac and Linux, we get the unlinkDir and addDir IPC events from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/251
if (isWindowsOS()) {
dispatch(_renameItem({ newName, itemUid, collectionUid }));
}
resolve();
})
.catch(reject);
});
};
@ -346,7 +368,16 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type)
.then(() => resolve())
.then(() => {
// In case of Mac and Linux, we get the unlinkDir IPC event from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/265
if (isWindowsOS()) {
dispatch(_deleteItem({ itemUid, collectionUid }));
}
resolve();
})
.catch((error) => reject(error));
}
return;
@ -569,6 +600,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'));
}
@ -586,6 +630,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'));
}
@ -620,6 +678,37 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
});
};
export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const baseEnv = findEnvironmentInCollection(collection, baseEnvUid);
if (!collection) {
return reject(new Error('Environmnent not found'));
}
ipcRenderer
.invoke('renderer:copy-environment', collection.pathname, name, baseEnv.variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
payload: name
}
})
)
)
.then(resolve)
.catch(reject);
});
};
export const renameEnvironment = (newName, environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();

View File

@ -28,7 +28,8 @@ import os from 'os';
const PATH_SEPARATOR = /Windows/i.test(os.release()) ? '\\' : '/';
const initialState = {
collections: []
collections: [],
collectionSortOrder: 'default'
};
export const collectionsSlice = createSlice({
@ -39,13 +40,19 @@ export const collectionsSlice = createSlice({
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
// and take some extra action based on that
// for example, when a env is created, we want to auto select it the env modal
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)) {
@ -70,6 +77,20 @@ export const collectionsSlice = createSlice({
removeCollection: (state, action) => {
state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid);
},
sortCollections: (state, action) => {
state.collectionSortOrder = action.payload.order;
switch (action.payload.order) {
case 'default':
state.collections = state.collections.sort((a, b) => a.importedAt - b.importedAt);
break;
case 'alphabetical':
state.collections = state.collections.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'reverseAlphabetical':
state.collections = state.collections.sort((a, b) => b.name.localeCompare(a.name));
break;
}
},
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@ -78,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);
@ -229,6 +258,17 @@ export const collectionsSlice = createSlice({
}
}
},
deleteRequestDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && item.draft) {
item.draft = null;
}
}
},
newEphemeralHttpRequest: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -307,6 +347,31 @@ export const collectionsSlice = createSlice({
}
}
},
updateAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.auth = item.draft.request.auth || {};
switch (action.payload.mode) {
case 'bearer':
item.draft.request.auth.mode = 'bearer';
item.draft.request.auth.bearer = action.payload.content;
break;
case 'basic':
item.draft.request.auth.mode = 'basic';
item.draft.request.auth.basic = action.payload.content;
break;
}
}
}
},
addQueryParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -563,6 +628,20 @@ export const collectionsSlice = createSlice({
}
}
},
updateRequestAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection && collection.items && collection.items.length) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.auth.mode = action.payload.mode;
}
}
},
updateRequestBodyMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@ -1035,7 +1114,6 @@ export const collectionsSlice = createSlice({
const { cancelTokenUid } = action.payload;
item.requestUid = requestUid;
item.requestState = 'queued';
item.response = null;
item.cancelTokenUid = cancelTokenUid;
}
@ -1141,7 +1219,9 @@ export const {
brunoConfigUpdateEvent,
renameCollection,
removeCollection,
sortCollections,
updateLastAction,
updateNextAction,
collectionUnlinkEnvFileEvent,
saveEnvironment,
selectEnvironment,
@ -1154,10 +1234,12 @@ export const {
requestCancelled,
responseReceived,
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
collectionClicked,
collectionFolderClicked,
requestUrlChanged,
updateAuth,
addQueryParam,
updateQueryParam,
deleteQueryParam,
@ -1170,6 +1252,7 @@ export const {
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,
updateRequestBody,
updateRequestGraphqlQuery,

View File

@ -53,3 +53,9 @@ body::-webkit-scrollbar-thumb,
background-color: #cdcdcd;
border-radius: 5rem;
}
/* making all the checkboxes and radios bigger */
input[type='checkbox'],
input[type='radio'] {
transform: scale(1.25);
}

View File

@ -9,13 +9,20 @@ const darkTheme = {
green: 'rgb(11 178 126)',
danger: '#f06f57',
muted: '#9d9d9d',
purple: '#cd56d6'
purple: '#cd56d6',
yellow: '#f59e0b'
},
bg: {
danger: '#d03544'
}
},
input: {
bg: 'rgb(65, 65, 65)',
border: 'rgb(65, 65, 65)',
focusBorder: 'rgb(65, 65, 65)'
},
variables: {
bg: 'rgb(48, 48, 49)',
@ -79,7 +86,11 @@ const darkTheme = {
get: '#8cd656',
post: '#cd56d6',
put: '#d69956',
delete: '#f06f57'
delete: '#f06f57',
// customize these colors if needed
patch: '#d69956',
options: '#d69956',
head: '#d69956'
}
},
@ -98,7 +109,8 @@ const darkTheme = {
responseSendIcon: '#555',
responseStatus: '#ccc',
responseOk: '#8cd656',
responseError: '#f06f57'
responseError: '#f06f57',
responseOverlayBg: 'rgba(30, 30, 30, 0.6)'
},
collection: {

View File

@ -9,13 +9,20 @@ const lightTheme = {
green: '#047857',
danger: 'rgb(185, 28, 28)',
muted: '#4b5563',
purple: '#8e44ad'
purple: '#8e44ad',
yellow: '#d97706'
},
bg: {
danger: '#dc3545'
}
},
input: {
bg: 'white',
border: '#ccc',
focusBorder: '#8b8b8b'
},
menubar: {
bg: 'rgb(44, 44, 44)'
},
@ -79,7 +86,11 @@ const lightTheme = {
get: 'rgb(5, 150, 105)',
post: '#8e44ad',
put: '#ca7811',
delete: 'rgb(185, 28, 28)'
delete: 'rgb(185, 28, 28)',
// customize these colors if needed
patch: '#ca7811',
options: '#ca7811',
head: '#ca7811'
}
},
@ -98,7 +109,8 @@ const lightTheme = {
responseSendIcon: 'rgb(209, 213, 219)',
responseStatus: 'rgb(117 117 117)',
responseOk: '#047857',
responseError: 'rgb(185, 28, 28)'
responseError: 'rgb(185, 28, 28)',
responseOverlayBg: 'rgba(255, 255, 255, 0.6)'
},
collection: {

View File

@ -0,0 +1,71 @@
const createContentType = (mode) => {
switch (mode) {
case 'json':
return 'application/json';
case 'xml':
return 'application/xml';
case 'formUrlEncoded':
return 'application/x-www-form-urlencoded';
case 'multipartForm':
return 'multipart/form-data';
default:
return 'application/json';
}
};
const createHeaders = (headers, mode) => {
const contentType = createContentType(mode);
const headersArray = headers
.filter((header) => header.enabled)
.map((header) => {
return {
name: header.name,
value: header.value
};
});
const headerNames = headersArray.map((header) => header.name);
if (!headerNames.includes('Content-Type')) {
return [...headersArray, { name: 'Content-Type', value: contentType }];
}
return headersArray;
};
const createQuery = (queryParams = []) => {
return queryParams.map((param) => {
return {
name: param.name,
value: param.value
};
});
};
const createPostData = (body) => {
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({ name: param.name, value: param.value }))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
export const buildHarRequest = (request) => {
return {
method: request.method,
url: request.url,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: createHeaders(request.headers, request.body.mode),
queryString: createQuery(request.params),
postData: createPostData(request.body),
headersSize: 0,
bodySize: 0
};
};

View File

@ -129,24 +129,28 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = targetItem.items || [];
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
@ -162,7 +166,9 @@ export const moveCollectionItemToRootOfCollection = (collection, draggedItem) =>
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
@ -203,7 +209,7 @@ export const getItemsToResequence = (parent, collection) => {
return itemsToResequence;
};
export const transformCollectionToSaveToIdb = (collection, options = {}) => {
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
return {
@ -281,6 +287,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.draft.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.draft.request.body.multipartForm)
},
auth: {
mode: get(si.draft.request, 'auth.mode', 'none'),
basic: {
username: get(si.draft.request, 'auth.basic.username', ''),
password: get(si.draft.request, 'auth.basic.password', '')
},
bearer: {
token: get(si.draft.request, 'auth.bearer.token', '')
}
},
script: si.draft.request.script,
vars: si.draft.request.vars,
assertions: si.draft.request.assertions,
@ -303,6 +319,16 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
},
auth: {
mode: get(si.request, 'auth.mode', 'none'),
basic: {
username: get(si.request, 'auth.basic.username', ''),
password: get(si.request, 'auth.basic.password', '')
},
bearer: {
token: get(si.request, 'auth.bearer.token', '')
}
},
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
@ -351,6 +377,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
url: _item.request.url,
params: [],
headers: [],
auth: _item.request.auth,
body: _item.request.body,
script: _item.request.script,
vars: _item.request.vars,
@ -445,6 +472,22 @@ export const humanizeRequestBodyMode = (mode) => {
return label;
};
export const humanizeRequestAuthMode = (mode) => {
let label = 'No Auth';
switch (mode) {
case 'basic': {
label = 'Basic Auth';
break;
}
case 'bearer': {
label = 'Bearer Token';
break;
}
}
return label;
};
export const refreshUidsInItem = (item) => {
item.uid = uuid();

View File

@ -42,3 +42,25 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
});
};
export const getCodeMirrorModeBasedOnContentType = (contentType) => {
if (!contentType || typeof contentType !== 'string') {
return 'application/text';
}
if (contentType.includes('json')) {
return 'application/ld+json';
} else if (contentType.includes('xml')) {
return 'application/xml';
} else if (contentType.includes('html')) {
return 'application/html';
} else if (contentType.includes('text')) {
return 'application/text';
} else if (contentType.includes('application/edn')) {
return 'application/xml';
} else if (contentType.includes('yaml')) {
return 'application/yaml';
} else {
return 'application/text';
}
};

View File

@ -51,6 +51,17 @@ export const safeStringifyJSON = (obj, indent = false) => {
}
};
export const safeParseXML = (str, options) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
try {
return xmlFormat(str, options);
} catch (e) {
return str;
}
};
// Remove any characters that are not alphanumeric, spaces, hyphens, or underscores
export const normalizeFileName = (name) => {
if (!name) {
@ -80,16 +91,6 @@ export const getContentType = (headers) => {
return contentType[0];
}
}
return '';
};
export const formatResponse = (response) => {
let type = getContentType(response.headers);
if (type.includes('json')) {
return safeStringifyJSON(response.data, true);
}
if (type.includes('xml')) {
return xmlFormat(response.data, { collapseContent: true });
}
return response.data;
};

View File

@ -1,6 +1,7 @@
import trim from 'lodash/trim';
import path from 'path';
import slash from './slash';
import platform from 'platform';
export const isElectron = () => {
if (!window) {
@ -33,3 +34,17 @@ export const getDirectoryName = (pathname) => {
return path.dirname(pathname);
};
export const isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('windows');
};
export const isMacOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('os x');
};

View File

@ -30,14 +30,42 @@ const parseGraphQL = (text) => {
}
};
const transformInsomniaRequestItem = (request) => {
const addSuffixToDuplicateName = (item, index, allItems) => {
// Check if the request name already exist and if so add a number suffix
const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
if (otherItem.name === item.name && otherIndex < index) {
nameSuffix++;
}
return nameSuffix;
}, 0);
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);
const brunoRequestItem = {
uid: uuid(),
name: request.name,
name,
type: 'http-request',
request: {
url: request.url,
method: request.method,
auth: {
mode: 'none',
basic: null,
bearer: null
},
headers: [],
params: [],
body: {
@ -71,7 +99,22 @@ const transformInsomniaRequestItem = (request) => {
});
});
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';
@ -143,14 +186,15 @@ const parseInsomniaCollection = (data) => {
resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
const folders = requestGroups.map((folder) => {
const folders = requestGroups.map((folder, index, allFolder) => {
const name = addSuffixToDuplicateName(folder, index, allFolder);
const requests = resources.filter(
(resource) => resource._type === 'request' && resource.parentId === folder._id
);
return {
uid: uuid(),
name: folder.name,
name,
type: 'folder',
items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
};

View File

@ -1,10 +1,8 @@
export const sendNetworkRequest = async (item, collection, environment, collectionVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const timeStart = Date.now();
sendHttpRequest(item, collection, environment, collectionVariables)
.then((response) => {
const timeEnd = Date.now();
resolve({
state: 'success',
data: response.data,
@ -12,7 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
size: response.headers['content-length'] || 0,
status: response.status,
statusText: response.statusText,
duration: timeEnd - timeStart
duration: response.duration
});
})
.catch((err) => reject(err));
@ -31,11 +29,11 @@ const sendHttpRequest = async (item, collection, environment, collectionVariable
});
};
export const fetchGqlSchema = async (endpoint, environment) => {
export const fetchGqlSchema = async (endpoint, environment, request, collection) => {
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, collection).then(resolve).catch(reject);
});
};

View File

@ -53,3 +53,12 @@ export const splitOnFirst = (str, char) => {
return [str.slice(0, index), str.slice(index + 1)];
};
export const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
};

View File

@ -1,5 +1,17 @@
# Changelog
## 0.13.0
- feat(#306) Module whitelisting and filesystem access support
## 0.12.0
- show response time in milliseconds per request and total
## 0.11.0
- fix(#119) Support for Basic and Bearer Auth
## 0.10.1
- fix(#233) Fixed Issue related to content header parsing

View File

@ -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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v4</pre>\n</body>\n</html>\n"
},
"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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v2</pre>\n</body>\n</html>\n"
},
"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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot GET /test/v3</pre>\n</body>\n</html>\n"
},
"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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test/v1</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [
{
"uid": "PpKLK6I38I5_ibw4lZqLb",
"lhsExpr": "res.status",
"rhsExpr": "eq 200",
"rhsOperand": "200",
"operator": "eq",
"status": "fail",
"error": "expected 404 to equal 200"
}
],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/test",
"headers": {}
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "144",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "HEAD",
"url": "http://localhost:3000/",
"headers": {}
},
"response": {
"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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/",
"headers": {
"content-type": "multipart/form-data; boundary=--------------------------897965859410704836065858"
},
"data": {
"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": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/",
"headers": {
"content-type": "application/x-www-form-urlencoded"
},
"data": "a=b&c=d"
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "140",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
},
{
"request": {
"method": "POST",
"url": "http://localhost:3000/test",
"headers": {
"content-type": "text/xml"
},
"data": "<xml>\n <test>1</test>\n</xml>"
},
"response": {
"status": 404,
"statusText": "Not Found",
"headers": {
"x-powered-by": "Express",
"content-security-policy": "default-src 'none'",
"x-content-type-options": "nosniff",
"content-type": "text/html; charset=utf-8",
"content-length": "144",
"date": "Fri, 29 Sep 2023 00:37:50 GMT",
"connection": "close"
},
"data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot POST /test</pre>\n</body>\n</html>\n"
},
"error": null,
"assertionResults": [],
"testResults": []
}
]

View File

@ -1,6 +1,6 @@
{
"name": "@usebruno/cli",
"version": "0.10.1",
"version": "0.13.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",
@ -21,14 +24,17 @@
"package.json"
],
"dependencies": {
"@usebruno/js": "0.6.0",
"@usebruno/lang": "0.4.0",
"@usebruno/js": "0.8.0",
"@usebruno/lang": "0.5.0",
"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",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^9.1.4",
"lodash": "^4.17.21",
"mustache": "^4.2.0",

View File

@ -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
};

View File

@ -0,0 +1,55 @@
const Handlebars = require('handlebars');
const { forOwn, cloneDeep } = require('lodash');
const interpolateEnvVars = (str, processEnvVars) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
const template = Handlebars.compile(str, { noEscape: true });
return template({
process: {
env: {
...processEnvVars
}
}
});
};
const interpolateString = (str, { envVars, collectionVariables, processEnvVars }) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
processEnvVars = processEnvVars || {};
collectionVariables = collectionVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = envVars ? cloneDeep(envVars) : {};
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
forOwn(envVars, (value, key) => {
envVars[key] = interpolateEnvVars(value, processEnvVars);
});
const template = Handlebars.compile(str, { noEscape: true });
// collectionVariables take precedence over envVars
const combinedVars = {
...envVars,
...collectionVariables,
process: {
env: {
...processEnvVars
}
}
};
return template(combinedVars);
};
module.exports = {
interpolateString
};

View File

@ -109,6 +109,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
}
}
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
if (request.auth) {
const username = interpolate(request.auth.username) || '';
const password = interpolate(request.auth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
delete request.auth;
}
return request;
};

View File

@ -1,4 +1,5 @@
const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const prepareRequest = (request) => {
const headers = {};
@ -18,6 +19,20 @@ const prepareRequest = (request) => {
headers: headers
};
// Authentication
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')}`;
}
}
request.body = request.body || {};
if (request.body.mode === 'json') {
@ -25,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;
}
@ -64,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';

View File

@ -1,15 +1,19 @@
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 https = require('https');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
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,
@ -20,11 +24,13 @@ const runSingleRequest = async function (
processEnvVars,
brunoConfig
) {
let request;
try {
let request;
request = prepareRequest(bruJson.request);
const scriptingConfig = get(brunoConfig, 'scripts', {});
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
@ -55,41 +61,17 @@ const runSingleRequest = async function (
if (requestScriptFile && requestScriptFile.length) {
const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runRequestScript(
requestScriptFile,
decomment(requestScriptFile),
request,
envVariables,
collectionVariables,
collectionPath,
null,
processEnvVars
processEnvVars,
scriptingConfig
);
}
// set proxy if enabled
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
if (proxyEnabled) {
const proxyProtocol = get(brunoConfig, 'proxy.protocol');
const proxyHostname = get(brunoConfig, 'proxy.hostname');
const proxyPort = get(brunoConfig, 'proxy.port');
const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false);
const proxyConfig = {
protocol: proxyProtocol,
hostname: proxyHostname,
port: proxyPort
};
if (proxyAuthEnabled) {
const proxyAuthUsername = get(brunoConfig, 'proxy.auth.username');
const proxyAuthPassword = get(brunoConfig, 'proxy.auth.password');
proxyConfig.auth = {
username: proxyAuthUsername,
password: proxyAuthPassword
};
}
request.proxy = proxyConfig;
}
// interpolate variables inside request
interpolateVars(request, envVariables, collectionVariables, processEnvVars);
@ -111,7 +93,39 @@ const runSingleRequest = async function (
}
}
if (Object.keys(httpsAgentRequestFields).length > 0) {
// set proxy if enabled
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
if (proxyEnabled) {
let proxy;
const interpolationOptions = {
envVars: envVariables,
collectionVariables,
processEnvVars
};
const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions);
const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions);
const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false);
interpolateString;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions);
proxy = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`;
} else {
proxy = `${proxyProtocol}://${proxyHostname}:${proxyPort}`;
}
request.httpsAgent = new HttpsProxyAgent(
proxy,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new HttpProxyAgent(proxy);
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
@ -122,10 +136,51 @@ 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');
@ -147,21 +202,22 @@ const runSingleRequest = async function (
if (responseScriptFile && responseScriptFile.length) {
const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runResponseScript(
responseScriptFile,
decomment(responseScriptFile),
request,
response,
envVariables,
collectionVariables,
collectionPath,
null,
processEnvVars
processEnvVars,
scriptingConfig
);
}
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
if (assertions && assertions.length) {
if (assertions) {
const assertRuntime = new AssertRuntime();
assertionResults = assertRuntime.runAssertions(
assertions,
@ -185,17 +241,18 @@ const runSingleRequest = async function (
// run tests
let testResults = [];
const testFile = get(bruJson, 'request.tests');
if (testFile && testFile.length) {
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const result = await testRuntime.runTests(
testFile,
decomment(testFile),
request,
response,
envVariables,
collectionVariables,
collectionPath,
null,
processEnvVars
processEnvVars,
scriptingConfig
);
testResults = get(result, 'results', []);
}
@ -221,102 +278,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 && assertions.length) {
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 (testFile && testFile.length) {
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: []
};
}
};

View File

@ -0,0 +1,42 @@
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'];
if (error.response) {
error.response.headers['request-duration'] = end - start;
}
}
return Promise.reject(error);
}
);
return instance;
}
module.exports = {
makeAxiosInstance
};

View File

@ -38,6 +38,7 @@ const bruToJson = (bru) => {
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
@ -49,6 +50,7 @@ const bruToJson = (bru) => {
};
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
return transformedJson;
} catch (err) {

View File

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

View File

@ -1,5 +1,5 @@
{
"version": "v0.16.5",
"version": "v0.21.1",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
@ -14,13 +14,14 @@
"test": "jest"
},
"dependencies": {
"@usebruno/js": "0.6.0",
"@usebruno/lang": "0.4.0",
"@usebruno/js": "0.8.0",
"@usebruno/lang": "0.5.0",
"@usebruno/schema": "0.5.0",
"about-window": "^1.15.2",
"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",
@ -30,6 +31,8 @@
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
"handlebars": "^4.7.8",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"is-valid-path": "^0.1.1",
"lodash": "^4.17.21",
"mustache": "^4.2.0",

View File

@ -24,7 +24,10 @@ const template = [
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' }
{ role: 'selectAll' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' }
]
},
{

View File

@ -2,12 +2,10 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasJsonExtension, hasBruExtension, writeFile } = require('../utils/filesystem');
const { bruToEnvJson, envJsonToBru, bruToJson, jsonToBru } = require('../bru');
const { hasBruExtension } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { isLegacyEnvFile, migrateLegacyEnvFile, isLegacyBruFile, migrateLegacyBruFile } = require('../bru/migrate');
const { itemSchema } = require('@usebruno/schema');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
const { decryptString } = require('../utils/encryption');
@ -17,13 +15,6 @@ const EnvironmentSecretsStore = require('../store/env-secrets');
const environmentSecretsStore = new EnvironmentSecretsStore();
const isJsonEnvironmentConfig = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return dirname === collectionPath && basename === 'environments.json';
};
const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
@ -87,11 +78,6 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
let bruContent = fs.readFileSync(pathname, 'utf8');
// migrate old env json to bru file
if (isLegacyEnvFile(bruContent)) {
bruContent = await migrateLegacyEnvFile(bruContent, pathname);
}
file.data = bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
@ -103,7 +89,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 +123,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);
}
});
@ -205,57 +191,10 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
}
}
if (isJsonEnvironmentConfig(pathname, collectionPath)) {
try {
const dirname = path.dirname(pathname);
const bruContent = fs.readFileSync(pathname, 'utf8');
const jsonData = JSON.parse(bruContent);
const envDirectory = path.join(dirname, 'environments');
if (!fs.existsSync(envDirectory)) {
fs.mkdirSync(envDirectory);
}
for (const env of jsonData) {
const bruEnvFilename = path.join(envDirectory, `${env.name}.bru`);
const bruContent = envJsonToBru(env);
await writeFile(bruEnvFilename, bruContent);
}
await fs.unlinkSync(pathname);
} catch (err) {
// do nothing
}
return;
}
if (isBruEnvironmentConfig(pathname, collectionPath)) {
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
}
// migrate old json files to bru
if (hasJsonExtension(pathname)) {
try {
const json = fs.readFileSync(pathname, 'utf8');
const jsonData = JSON.parse(json);
await itemSchema.validate(jsonData);
const content = jsonToBru(jsonData);
const re = /(.*)\.json$/;
const subst = `$1.bru`;
const bruFilename = pathname.replace(re, subst);
await writeFile(bruFilename, content);
await fs.unlinkSync(pathname);
} catch (err) {
// do nothing
}
}
if (hasBruExtension(pathname)) {
const file = {
meta: {
@ -268,11 +207,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
// migrate old bru format to new bru format
if (isLegacyBruFile(bruContent)) {
bruContent = await migrateLegacyBruFile(bruContent, pathname);
}
file.data = bruToJson(bruContent);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@ -404,11 +338,6 @@ class Watcher {
this.watchers[watchPath].close();
}
// todo
// enable this in a future release
// once we can confirm all older json based files have been auto migrated to .bru format
// watchPath = path.join(watchPath, '**/*.bru');
const self = this;
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {

View File

@ -1,6 +1,5 @@
const _ = require('lodash');
const { bruToJsonV2, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2 } = require('@usebruno/lang');
const { each } = require('lodash');
const bruToEnvJson = (bru) => {
try {
@ -10,7 +9,7 @@ const bruToEnvJson = (bru) => {
// this need to be evaluated and safely removed
// i don't see it being used in schema validation
if (json && json.variables && json.variables.length) {
each(json.variables, (v) => (v.type = 'text'));
_.each(json.variables, (v) => (v.type = 'text'));
}
return json;
@ -61,6 +60,7 @@ const bruToJson = (bru) => {
url: _.get(json, 'http.url'),
params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
script: _.get(json, 'script', {}),
vars: _.get(json, 'vars', {}),
@ -69,6 +69,7 @@ const bruToJson = (bru) => {
}
};
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
return transformedJson;
@ -104,10 +105,12 @@ const jsonToBru = (json) => {
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {

View File

@ -1,99 +0,0 @@
const {
bruToEnvJson: bruToEnvJsonV1,
bruToJson: bruToJsonV1,
jsonToBruV2,
envJsonToBruV2
} = require('@usebruno/lang');
const _ = require('lodash');
const { writeFile } = require('../utils/filesystem');
const isLegacyEnvFile = (bruContent = '') => {
bruContent = bruContent.trim();
let regex = /^vars[\s\S]*\/vars$/;
return regex.test(bruContent);
};
const migrateLegacyEnvFile = async (bruContent, pathname) => {
const envJson = bruToEnvJsonV1(bruContent);
const newBruContent = envJsonToBruV2(envJson);
await writeFile(pathname, newBruContent);
return newBruContent;
};
const isLegacyBruFile = (bruContent = '') => {
bruContent = bruContent.trim();
let lines = bruContent.split(/\r?\n/);
let hasName = false;
let hasMethod = false;
let hasUrl = false;
for (let line of lines) {
line = line.trim();
if (line.startsWith('name')) {
hasName = true;
} else if (line.startsWith('method')) {
hasMethod = true;
} else if (line.startsWith('url')) {
hasUrl = true;
}
}
return hasName && hasMethod && hasUrl;
};
const migrateLegacyBruFile = async (bruContent, pathname) => {
const json = bruToJsonV1(bruContent);
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
} else if (type === 'graphql-request') {
type = 'graphql';
} else {
type = 'http';
}
let script = {};
let legacyScript = _.get(json, 'request.script');
if (legacyScript && legacyScript.trim().length > 0) {
script = {
res: legacyScript
};
}
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: _.get(json, 'seq')
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
body: _.get(json, 'request.body.mode', 'none')
},
query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
body: _.get(json, 'request.body', {}),
script: script,
tests: _.get(json, 'request.tests', '')
};
const newBruContent = jsonToBruV2(bruJson);
await writeFile(pathname, newBruContent);
return newBruContent;
};
module.exports = {
isLegacyEnvFile,
migrateLegacyEnvFile,
isLegacyBruFile,
migrateLegacyBruFile
};

View File

@ -9,6 +9,7 @@ const LastOpenedCollections = require('./store/last-opened-collections');
const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const Watcher = require('./app/watcher');
const { loadWindowState, saveWindowState } = require('./utils/window');
const lastOpenedCollections = new LastOpenedCollections();
@ -16,9 +17,7 @@ setContentSecurityPolicy(`
default-src * 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * 'unsafe-inline';
base-uri 'none';
form-action 'none';
img-src 'self' data:image/svg+xml;
`);
const menu = Menu.buildFromTemplate(menuTemplate);
@ -29,14 +28,24 @@ let watcher;
// Prepare the renderer once the app is ready
app.on('ready', async () => {
const { x, y, width, height } = loadWindowState();
mainWindow = new BrowserWindow({
width: 1280,
height: 768,
x,
y,
width,
height,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
preload: path.join(__dirname, 'preload.js'),
webviewTag: true
},
title: 'Bruno',
icon: path.join(__dirname, 'about/256x256.png')
// we will bring this back
// see https://github.com/usebruno/bruno/issues/440
// autoHideMenuBar: true
});
const url = isDev
@ -50,6 +59,9 @@ app.on('ready', async () => {
mainWindow.loadURL(url);
watcher = new Watcher();
mainWindow.on('resize', () => saveWindowState(mainWindow));
mainWindow.on('move', () => saveWindowState(mainWindow));
mainWindow.webContents.on('new-window', function (e, url) {
e.preventDefault();
require('electron').shell.openExternal(url);

View File

@ -151,6 +151,28 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
// copy environment
ipcMain.handle('renderer:copy-environment', async (event, collectionPathname, name, baseVariables) => {
try {
const envDirPath = path.join(collectionPathname, 'environments');
if (!fs.existsSync(envDirPath)) {
await createDirectory(envDirPath);
}
const envFilePath = path.join(envDirPath, `${name}.bru`);
if (fs.existsSync(envFilePath)) {
throw new Error(`environment: ${envFilePath} already exists`);
}
const content = envJsonToBru({
variables: baseVariables
});
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
}
});
// save environment
ipcMain.handle('renderer:save-environment', async (event, collectionPathname, environment) => {
try {
@ -464,6 +486,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return Promise.reject(error);
}
});
ipcMain.handle('renderer:open-devtools', async () => {
mainWindow.webContents.openDevTools();
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {

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