Merge branch 'main' into bugfix/negative-timeout

This commit is contained in:
Anoop M D 2023-10-18 22:56:29 +05:30 committed by GitHub
commit 0ca035f492
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1369 additions and 501 deletions

View File

@ -1,7 +1,13 @@
# Description # Description
<!-- Explain here the changes your PR introduces and text to help us understand the context of this change. --> <!-- Explain here the changes your PR introduces and text to help us understand the context of this change. -->
# Contribution Checklist: ### Contribution Checklist:
- [ ] **The pull request only addresses one issue or adds one feature.**
- [ ] **The pull request does not introduce any breaking changes** - [ ] **The pull request does not introduce any breaking changes**
- [ ] **I have added screenshots or gifs to help explain the change if applicable.**
- [ ] **I have read the [contribution guidelines](https://github.com/usebruno/bruno/blob/main/contributing.md).** - [ ] **I have read the [contribution guidelines](https://github.com/usebruno/bruno/blob/main/contributing.md).**
- [ ] **Create an issue and link to the pull request.** - [ ] **Create an issue and link to the pull request.**
Note: Keeping the PR small and focused helps make it easier to review and merge. If you have multiple changes you want to make, please consider submitting them as separate pull requests.

View File

@ -5,18 +5,16 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
jobs: jobs:
test: tests:
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version-file: '.nvmrc'
- name: Check package-lock.json
run: npm ci
- name: Install dependencies - name: Install dependencies
run: npm i --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Test Package bruno-query - name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query run: npm run test --workspace=packages/bruno-query
- name: Build Package bruno-query - name: Build Package bruno-query
@ -33,3 +31,15 @@ jobs:
run: npm run test --workspace=packages/bruno-cli run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-electron - name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron run: npm run test --workspace=packages/bruno-electron
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run Prettier
run: npm run test:prettier:web

View File

@ -1,4 +1,4 @@
**English** | [Русский](/contributing_ru.md) **English** | [Українська](/contributing_ua.md) | [Русский](/contributing_ru.md)
## Lets make bruno better, together !! ## Lets make bruno better, together !!

View File

@ -1,4 +1,4 @@
[English](/contributing.md) | **Русский** [English](/contributing.md) | [Українська](/contributing_ua.md) | **Русский**
## Давайте вместе сделаем Бруно лучше!!! ## Давайте вместе сделаем Бруно лучше!!!

37
contributing_ua.md Normal file
View File

@ -0,0 +1,37 @@
[English](/contributing.md) | **Українська** | [Русский](/contributing_ru.md)
## Давайте зробимо Bruno краще, разом !!
Я дуже радий що Ви бажаєте покращити 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 workspaces в цьому проекті
### Починаєм писати код
Будь ласка, зверніться до [development_ua.md](docs/development_ua.md) за інструкціями щодо запуску локального середовища розробки.
### Створення Pull Request-ів
- Будь ласка, робіть PR-и маленькими і сфокусованими на одній речі
- Будь ласка, слідуйте формату назв гілок
- feature/[назва feature]: Така гілка має містити зміни лише щодо конкретної feature
- Приклад: feature/dark-mode
- bugfix/[назва баґу]: Така гілка має містити лише виправлення конкретного багу
- Приклад: bugfix/bug-1

View File

@ -1,4 +1,4 @@
**English** | [Русский](/docs/development_ru.md) **English** | [Українська](/docs/development_ua.md) | [Русский](/docs/development_ru.md)
## Development ## Development

View File

@ -1,4 +1,4 @@
[English](/docs/development.md) | **Русский** [English](/docs/development.md) | [Українська](/docs/development_ua.md) | **Русский**
## Разработка ## Разработка

55
docs/development_ua.md Normal file
View File

@ -0,0 +1,55 @@
[English](/docs/development.md) | **Українська** | [Русский](/docs/development_ru.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
# запустіть додаток електрон (термінал 2)
npm run dev:electron
```
### Усунення несправностей
Ви можете зтикнутись із помилкою `Unsupported platform` коли запускаєте `npm install`. Щоб усунути цю проблему, вам потрібно видалити `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
```

2
package-lock.json generated
View File

@ -16717,7 +16717,7 @@
}, },
"packages/bruno-electron": { "packages/bruno-electron": {
"name": "bruno", "name": "bruno",
"version": "v0.24.0", "version": "v0.25.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-providers": "^3.425.0", "@aws-sdk/credential-providers": "^3.425.0",
"@usebruno/js": "0.8.0", "@usebruno/js": "0.8.0",

View File

@ -41,6 +41,7 @@
"build:electron:snap": "./scripts/build-electron.sh snap", "build:electron:snap": "./scripts/build-electron.sh snap",
"test:e2e": "npx playwright test", "test:e2e": "npx playwright test",
"test:report": "npx playwright show-report", "test:report": "npx playwright show-report",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install" "prepare": "husky install"
}, },
"overrides": { "overrides": {

View File

@ -8,6 +8,7 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "jest", "test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"" "prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
}, },
"dependencies": { "dependencies": {

View File

@ -1,7 +1,7 @@
import 'github-markdown-css/github-markdown.css'; import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get'; import get from 'lodash/get';
import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections'; import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme/index'; import { useTheme } from 'providers/Theme';
import { useState } from 'react'; import { useState } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';

View File

@ -1,13 +1,55 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import Tooltip from 'components/Tooltip';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
const ProxySettings = ({ proxyConfig, onUpdate }) => { const ProxySettings = ({ proxyConfig, onUpdate }) => {
const proxySchema = Yup.object({
use: Yup.string().oneOf(['global', 'true', 'false']),
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string()
.when('use', {
is: true,
then: (hostname) => hostname.required('Specify the hostname for your proxy.'),
otherwise: (hostname) => hostname.nullable()
})
.max(1024),
port: Yup.number()
.when('use', {
is: true,
then: (port) => port.required('Specify port between 1 and 65535').typeError('Specify port between 1 and 65535'),
otherwise: (port) => port.nullable().transform((_, val) => (val ? Number(val) : null))
})
.min(1)
.max(65535),
auth: Yup.object()
.when('enabled', {
is: true,
then: Yup.object({
enabled: Yup.boolean(),
username: Yup.string()
.when('enabled', {
is: true,
then: (username) => username.required('Specify username for proxy authentication.')
})
.max(1024),
password: Yup.string()
.when('enabled', {
is: true,
then: (password) => password.required('Specify password for proxy authentication.')
})
.max(1024)
})
})
.optional(),
bypassProxy: Yup.string().optional().max(1024)
});
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
enabled: proxyConfig.enabled || false, use: proxyConfig.use || 'global',
protocol: proxyConfig.protocol || 'http', protocol: proxyConfig.protocol || 'http',
hostname: proxyConfig.hostname || '', hostname: proxyConfig.hostname || '',
port: proxyConfig.port || '', port: proxyConfig.port || '',
@ -15,27 +57,33 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false,
username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', username: proxyConfig.auth ? proxyConfig.auth.username || '' : '',
password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' password: proxyConfig.auth ? proxyConfig.auth.password || '' : ''
}
}, },
validationSchema: Yup.object({ bypassProxy: proxyConfig.bypassProxy || ''
enabled: Yup.boolean(), },
protocol: Yup.string().oneOf(['http', 'https', 'socks5']), validationSchema: proxySchema,
hostname: Yup.string().max(1024),
port: Yup.number().min(0).max(65535),
auth: Yup.object({
enabled: Yup.boolean(),
username: Yup.string().max(1024),
password: Yup.string().max(1024)
})
}),
onSubmit: (values) => { onSubmit: (values) => {
onUpdate(values); proxySchema
.validate(values, { abortEarly: true })
.then((validatedProxy) => {
// serialize 'use' to boolean
if (validatedProxy.use === 'true') {
validatedProxy.use = true;
} else if (validatedProxy.use === 'false') {
validatedProxy.use = false;
}
onUpdate(validatedProxy);
})
.catch((error) => {
let errMsg = error.message || 'Preferences validation error';
toast.error(errMsg);
});
} }
}); });
useEffect(() => { useEffect(() => {
formik.setValues({ formik.setValues({
enabled: proxyConfig.enabled || false, use: proxyConfig.use === true ? 'true' : proxyConfig.use === false ? 'false' : 'global',
protocol: proxyConfig.protocol || 'http', protocol: proxyConfig.protocol || 'http',
hostname: proxyConfig.hostname || '', hostname: proxyConfig.hostname || '',
port: proxyConfig.port || '', port: proxyConfig.port || '',
@ -43,18 +91,66 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false, enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false,
username: proxyConfig.auth ? proxyConfig.auth.username || '' : '', username: proxyConfig.auth ? proxyConfig.auth.username || '' : '',
password: proxyConfig.auth ? proxyConfig.auth.password || '' : '' password: proxyConfig.auth ? proxyConfig.auth.password || '' : ''
} },
bypassProxy: proxyConfig.bypassProxy || ''
}); });
}, [proxyConfig]); }, [proxyConfig]);
return ( return (
<StyledWrapper> <StyledWrapper>
<h1 className="font-medium mb-3">Proxy Settings</h1>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="enabled"> <label className="settings-label flex items-center" htmlFor="enabled">
Enabled Config
<Tooltip
text={`
<div>
<ul>
<li><span style="width: 50px;display:inline-block;">global</span> - use global proxy config</li>
<li><span style="width: 50px;display:inline-block;">enabled</span> - use collection proxy config</li>
<li><span style="width: 50px;display:inline-block;">disable</span> - disable proxy</li>
</ul>
</div>
`}
tooltipId="request-var"
/>
</label> </label>
<input type="checkbox" name="enabled" checked={formik.values.enabled} onChange={formik.handleChange} /> <div className="flex items-center">
<label className="flex items-center">
<input
type="radio"
name="use"
value="global"
checked={formik.values.use === 'global'}
onChange={formik.handleChange}
className="mr-1"
/>
global
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="use"
value={'true'}
checked={formik.values.use === 'true'}
onChange={formik.handleChange}
className="mr-1"
/>
enabled
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="use"
value={'false'}
checked={formik.values.use === 'false'}
onChange={formik.handleChange}
className="mr-1"
/>
disabled
</label>
</div>
</div> </div>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol"> <label className="settings-label" htmlFor="protocol">
@ -83,6 +179,17 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
/> />
https https
</label> </label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks5"
checked={formik.values.protocol === 'socks4'}
onChange={formik.handleChange}
className="mr-1"
/>
socks4
</label>
<label className="flex items-center ml-4"> <label className="flex items-center ml-4">
<input <input
type="radio" type="radio"
@ -113,7 +220,7 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
value={formik.values.hostname || ''} value={formik.values.hostname || ''}
/> />
{formik.touched.hostname && formik.errors.hostname ? ( {formik.touched.hostname && formik.errors.hostname ? (
<div className="text-red-500">{formik.errors.hostname}</div> <div className="ml-3 text-red-500">{formik.errors.hostname}</div>
) : null} ) : null}
</div> </div>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
@ -132,7 +239,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
onChange={formik.handleChange} onChange={formik.handleChange}
value={formik.values.port} value={formik.values.port}
/> />
{formik.touched.port && formik.errors.port ? <div className="text-red-500">{formik.errors.port}</div> : null} {formik.touched.port && formik.errors.port ? (
<div className="ml-3 text-red-500">{formik.errors.port}</div>
) : null}
</div> </div>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.enabled"> <label className="settings-label" htmlFor="auth.enabled">
@ -163,7 +272,7 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
onChange={formik.handleChange} onChange={formik.handleChange}
/> />
{formik.touched.auth?.username && formik.errors.auth?.username ? ( {formik.touched.auth?.username && formik.errors.auth?.username ? (
<div className="text-red-500">{formik.errors.auth.username}</div> <div className="ml-3 text-red-500">{formik.errors.auth.username}</div>
) : null} ) : null}
</div> </div>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center">
@ -183,10 +292,30 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
onChange={formik.handleChange} onChange={formik.handleChange}
/> />
{formik.touched.auth?.password && formik.errors.auth?.password ? ( {formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="text-red-500">{formik.errors.auth.password}</div> <div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="bypassProxy">
Proxy Bypass
</label>
<input
id="bypassProxy"
type="text"
name="bypassProxy"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.bypassProxy || ''}
/>
{formik.touched.bypassProxy && formik.errors.bypassProxy ? (
<div className="ml-3 text-red-500">{formik.errors.bypassProxy}</div>
) : null}
</div>
<div className="mt-6"> <div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary"> <button type="submit" className="submit btn btn-sm btn-secondary">
Save Save

View File

@ -36,7 +36,7 @@ const CollectionSettings = ({ collection }) => {
brunoConfig.proxy = config; brunoConfig.proxy = config;
dispatch(updateBrunoConfig(brunoConfig, collection.uid)) dispatch(updateBrunoConfig(brunoConfig, collection.uid))
.then(() => { .then(() => {
toast.success('Collection settings updated successfully'); toast.success('Collection settings updated successfully.');
}) })
.catch((err) => console.log(err) && toast.error('Failed to update collection settings')); .catch((err) => console.log(err) && toast.error('Failed to update collection settings'));
}; };

View File

@ -5,7 +5,7 @@ import { toastError } from 'utils/common/error';
import usePrevious from 'hooks/usePrevious'; import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails'; import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment'; import CreateEnvironment from '../CreateEnvironment';
import { IconUpload } from '@tabler/icons'; import { IconDownload } from '@tabler/icons';
import ImportEnvironment from '../ImportEnvironment'; import ImportEnvironment from '../ImportEnvironment';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -73,7 +73,7 @@ const EnvironmentList = ({ collection }) => {
</div> </div>
<div className="mt-auto flex items-center btn-import-environment" onClick={() => setOpenImportModal(true)}> <div className="mt-auto flex items-center btn-import-environment" onClick={() => setOpenImportModal(true)}>
<IconUpload size={12} strokeWidth={2} /> <IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span> <span className="label ml-1 text-xs">Import</span>
</div> </div>
</div> </div>

View File

@ -55,7 +55,7 @@ const General = ({ close }) => {
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<label className="block font-medium mr-2 select-none" style={{ minWidth: 200 }} htmlFor="sslVerification"> <label className="block font-medium mr-2 select-none" style={{ minWidth: 200 }} htmlFor="sslVerification">
SSL Certificate Verification SSL/TLS Certificate Verification
</label> </label>
<input <input
id="ssl-cert-verification" id="ssl-cert-verification"

View File

@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.settings-label {
width: 80px;
}
.textbox {
border: 1px solid #ccc;
padding: 0.15rem 0.45rem;
box-shadow: none;
outline: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
&:focus {
border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important;
outline: none !important;
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,292 @@
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
const ProxySettings = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const proxySchema = Yup.object({
enabled: Yup.boolean(),
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string()
.when('enabled', {
is: true,
then: (hostname) => hostname.required('Specify the hostname for your proxy.'),
otherwise: (hostname) => hostname.nullable()
})
.max(1024),
port: Yup.number()
.when('enabled', {
is: true,
then: (port) => port.required('Specify port between 1 and 65535').typeError('Specify port between 1 and 65535'),
otherwise: (port) => port.nullable().transform((_, val) => (val ? Number(val) : null))
})
.min(1)
.max(65535),
auth: Yup.object()
.when('enabled', {
is: true,
then: Yup.object({
enabled: Yup.boolean(),
username: Yup.string()
.when(['enabled'], {
is: true,
then: (username) => username.required('Specify username for proxy authentication.')
})
.max(1024),
password: Yup.string()
.when('enabled', {
is: true,
then: (password) => password.required('Specify password for proxy authentication.')
})
.max(1024)
})
})
.optional(),
bypassProxy: Yup.string().optional().max(1024)
});
const formik = useFormik({
initialValues: {
enabled: preferences.proxy.enabled || false,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || 0,
auth: {
enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false,
username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '',
password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : ''
},
bypassProxy: preferences.proxy.bypassProxy || ''
},
validationSchema: proxySchema,
onSubmit: (values) => {
onUpdate(values);
}
});
const onUpdate = (values) => {
proxySchema
.validate(values, { abortEarly: true })
.then((validatedProxy) => {
dispatch(
savePreferences({
...preferences,
proxy: validatedProxy
})
).then(() => {
close();
});
})
.catch((error) => {
let errMsg = error.message || 'Preferences validation error';
toast.error(errMsg);
});
};
useEffect(() => {
formik.setValues({
enabled: preferences.proxy.enabled || false,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || '',
auth: {
enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false,
username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '',
password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : ''
},
bypassProxy: preferences.proxy.bypassProxy || ''
});
}, [preferences]);
return (
<StyledWrapper>
<h1 className="font-medium mb-3">Global Proxy Settings</h1>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="enabled">
Enabled
</label>
<input type="checkbox" name="enabled" checked={formik.values.enabled} onChange={formik.handleChange} />
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
Protocol
</label>
<div className="flex items-center">
<label className="flex items-center">
<input
type="radio"
name="protocol"
value="http"
checked={formik.values.protocol === 'http'}
onChange={formik.handleChange}
className="mr-1"
/>
http
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="https"
checked={formik.values.protocol === 'https'}
onChange={formik.handleChange}
className="mr-1"
/>
https
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks5"
checked={formik.values.protocol === 'socks4'}
onChange={formik.handleChange}
className="mr-1"
/>
socks4
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks5"
checked={formik.values.protocol === 'socks5'}
onChange={formik.handleChange}
className="mr-1"
/>
socks5
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="hostname">
Hostname
</label>
<input
id="hostname"
type="text"
name="hostname"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.hostname || ''}
/>
{formik.touched.hostname && formik.errors.hostname ? (
<div className="ml-3 text-red-500">{formik.errors.hostname}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="port">
Port
</label>
<input
id="port"
type="number"
name="port"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.port}
/>
{formik.touched.port && formik.errors.port ? (
<div className="ml-3 text-red-500">{formik.errors.port}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.enabled">
Auth
</label>
<input
type="checkbox"
name="auth.enabled"
checked={formik.values.auth.enabled}
onChange={formik.handleChange}
/>
</div>
<div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.username">
Username
</label>
<input
id="auth.username"
type="text"
name="auth.username"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.username}
onChange={formik.handleChange}
/>
{formik.touched.auth?.username && formik.errors.auth?.username ? (
<div className="ml-3 text-red-500">{formik.errors.auth.username}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.password">
Password
</label>
<input
id="auth.password"
type="text"
name="auth.password"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
/>
{formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
) : null}
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="bypassProxy">
Proxy Bypass
</label>
<input
id="bypassProxy"
type="text"
name="bypassProxy"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.bypassProxy || ''}
/>
{formik.touched.bypassProxy && formik.errors.bypassProxy ? (
<div className="ml-3 text-red-500">{formik.errors.bypassProxy}</div>
) : null}
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-md btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default ProxySettings;

View File

@ -5,6 +5,7 @@ import Support from './Support';
import General from './General'; import General from './General';
import Font from './Font'; import Font from './Font';
import Theme from './Theme'; import Theme from './Theme';
import Proxy from './ProxySettings';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const Preferences = ({ onClose }) => { const Preferences = ({ onClose }) => {
@ -22,6 +23,10 @@ const Preferences = ({ onClose }) => {
return <General close={onClose} />; return <General close={onClose} />;
} }
case 'proxy': {
return <Proxy close={onClose} />;
}
case 'theme': { case 'theme': {
return <Theme close={onClose} />; return <Theme close={onClose} />;
} }
@ -49,6 +54,9 @@ const Preferences = ({ onClose }) => {
<div className={getTabClassname('font')} role="tab" onClick={() => setTab('font')}> <div className={getTabClassname('font')} role="tab" onClick={() => setTab('font')}>
Font Font
</div> </div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
</div>
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}> <div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
Support Support
</div> </div>

View File

@ -36,12 +36,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
const request = item.draft ? item.draft.request : item.request; const request = item.draft ? item.draft.request : item.request;
let { let { schema, loadSchema, isLoading: isSchemaLoading } = useGraphqlSchema(url, environment, request, collection);
schema,
loadSchema,
isLoading: isSchemaLoading,
error: schemaError
} = useGraphqlSchema(url, environment, request, collection);
const loadGqlSchema = () => { const loadGqlSchema = () => {
if (!isSchemaLoading) { if (!isSchemaLoading) {

View File

@ -26,7 +26,12 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
const loadSchema = () => { const loadSchema = () => {
setIsLoading(true); setIsLoading(true);
fetchGqlSchema(endpoint, environment, request, collection) fetchGqlSchema(endpoint, environment, request, collection)
.then((res) => res.data) .then((res) => {
if (!res || res.status !== 200) {
return Promise.reject(new Error(res.statusText));
}
return res.data;
})
.then((s) => { .then((s) => {
if (s && s.data) { if (s && s.data) {
setSchema(buildClientSchema(s.data)); setSchema(buildClientSchema(s.data));
@ -40,7 +45,7 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
.catch((err) => { .catch((err) => {
setIsLoading(false); setIsLoading(false);
setError(err); setError(err);
toast.error('Error occurred while loading GraphQL Schema'); toast.error(`Error occurred while loading GraphQL Schema: ${err.message}`);
}); });
}; };

View File

@ -4,7 +4,7 @@ const Wrapper = styled.div`
font-size: 0.8125rem; font-size: 0.8125rem;
.body-mode-selector { .body-mode-selector {
background: ${(props) => props.theme.requestTabPanel.bodyModeSelect.color}; background: transparent;
border-radius: 3px; border-radius: 3px;
.dropdown-item { .dropdown-item {
@ -15,6 +15,10 @@ const Wrapper = styled.div`
.label-item { .label-item {
padding: 0.2rem 0.6rem !important; padding: 0.2rem 0.6rem !important;
} }
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
} }
.caret { .caret {

View File

@ -6,16 +6,19 @@ import { useDispatch } from 'react-redux';
import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections'; import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestBodyMode } from 'utils/collections'; import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
import { toastError } from 'utils/common/error';
const RequestBodyMode = ({ item, collection }) => { const RequestBodyMode = ({ item, collection }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const dropdownTippyRef = useRef(); const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode'); const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const bodyMode = body?.mode;
const Icon = forwardRef((props, ref) => { const Icon = forwardRef((props, ref) => {
return ( return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none"> <div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} /> {humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
</div> </div>
); );
@ -31,6 +34,24 @@ const RequestBodyMode = ({ item, collection }) => {
); );
}; };
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
try {
const bodyJson = JSON.parse(body.json);
const prettyBodyJson = JSON.stringify(bodyJson, null, 2);
dispatch(
updateRequestBody({
content: prettyBodyJson,
itemUid: item.uid,
collectionUid: collection.uid
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
}
};
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector"> <div className="inline-flex items-center cursor-pointer body-mode-selector">
@ -103,6 +124,11 @@ const RequestBodyMode = ({ item, collection }) => {
</div> </div>
</Dropdown> </Dropdown>
</div> </div>
{bodyMode === 'json' && (
<button className="ml-1" onClick={onPrettify}>
Prettify
</button>
)}
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@ -13,6 +13,10 @@ const ResponseSize = ({ size }) => {
sizeToDisplay = size + 'B'; sizeToDisplay = size + 'B';
} }
return <StyledWrapper className="ml-4">{sizeToDisplay}</StyledWrapper>; return (
<StyledWrapper title={size.toLocaleString() + 'B'} className="ml-4">
{sizeToDisplay}
</StyledWrapper>
);
}; };
export default ResponseSize; export default ResponseSize;

View File

@ -88,8 +88,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
}); });
const handleClick = (event) => { const handleClick = (event) => {
switch (event.button) {
case 0: // left click
if (isItemARequest(item)) { if (isItemARequest(item)) {
dispatch(hideHomePage()); dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) { if (itemIsOpenedInTabs(item, tabs)) {
@ -115,8 +113,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
collectionUid: collection.uid collectionUid: collection.uid
}) })
); );
return; };
case 2: // right click
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current; const _menuDropdown = dropdownTippyRef.current;
if (_menuDropdown) { if (_menuDropdown) {
let menuDropdownBehavior = 'show'; let menuDropdownBehavior = 'show';
@ -125,8 +124,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
} }
_menuDropdown[menuDropdownBehavior](); _menuDropdown[menuDropdownBehavior]();
} }
return;
}
}; };
const handleDoubleClick = (event) => { const handleDoubleClick = (event) => {
@ -203,7 +200,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
? indents.map((i) => { ? indents.map((i) => {
return ( return (
<div <div
onMouseUp={handleClick} onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
className="indent-block" className="indent-block"
key={i} key={i}
@ -219,7 +217,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
}) })
: null} : null}
<div <div
onMouseUp={handleClick} onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
className="flex flex-grow items-center h-full overflow-hidden" className="flex flex-grow items-center h-full overflow-hidden"
style={{ style={{

View File

@ -18,7 +18,7 @@ const RemoveCollection = ({ onClose, collection }) => {
return ( return (
<Modal size="sm" title="Remove Collection" confirmText="Remove" handleConfirm={onConfirm} handleCancel={onClose}> <Modal size="sm" title="Remove Collection" confirmText="Remove" handleConfirm={onConfirm} handleCancel={onClose}>
Are you sure you want to remove this collection? Are you sure you want to delete collection <span className="font-semibold">{collection.name}</span> ?
</Modal> </Modal>
); );
}; };

View File

@ -66,12 +66,11 @@ const Collection = ({ collection, searchText }) => {
}); });
const handleClick = (event) => { const handleClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
switch (event.button) {
case 0: // left click
dispatch(collectionClicked(collection.uid)); dispatch(collectionClicked(collection.uid));
return; };
case 2: // right click
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) { if (_menuDropdown) {
let menuDropdownBehavior = 'show'; let menuDropdownBehavior = 'show';
if (_menuDropdown.state.isShown) { if (_menuDropdown.state.isShown) {
@ -79,8 +78,6 @@ const Collection = ({ collection, searchText }) => {
} }
_menuDropdown[menuDropdownBehavior](); _menuDropdown[menuDropdownBehavior]();
} }
return;
}
}; };
const handleExportClick = () => { const handleExportClick = () => {
@ -138,7 +135,11 @@ const Collection = ({ collection, searchText }) => {
<CollectionProperties collection={collection} onClose={() => setCollectionPropertiesModal(false)} /> <CollectionProperties collection={collection} onClose={() => setCollectionPropertiesModal(false)} />
)} )}
<div className="flex py-1 collection-name items-center" ref={drop}> <div className="flex py-1 collection-name items-center" ref={drop}>
<div className="flex flex-grow items-center overflow-hidden" onMouseUp={handleClick}> <div
className="flex flex-grow items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight <IconChevronRight
size={16} size={16}
strokeWidth={2} strokeWidth={2}

View File

@ -76,7 +76,14 @@ const CreateCollection = ({ onClose }) => {
name="collectionName" name="collectionName"
ref={inputRef} ref={inputRef}
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
onChange={formik.handleChange} onChange = {
(e) => {
formik.handleChange(e);
if (formik.values.collectionName === formik.values.collectionFolderName) {
formik.setFieldValue("collectionFolderName", e.target.value);
}
}
}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"

View File

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

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions'; import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconBrandGithub, IconPlus, IconUpload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons'; import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
import Bruno from 'components/Bruno'; import Bruno from 'components/Bruno';
import CreateCollection from 'components/Sidebar/CreateCollection'; import CreateCollection from 'components/Sidebar/CreateCollection';
@ -69,7 +69,7 @@ const Welcome = () => {
<span className="label ml-2">Open Collection</span> <span className="label ml-2">Open Collection</span>
</div> </div>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}> <div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconUpload size={18} strokeWidth={2} /> <IconDownload size={18} strokeWidth={2} />
<span className="label ml-2" id="import-collection"> <span className="label ml-2" id="import-collection">
Import Collection Import Collection
</span> </span>

View File

@ -45,6 +45,8 @@ export const fetchGqlSchema = async (endpoint, environment, request, collection)
export const cancelNetworkRequest = async (cancelTokenUid) => { export const cancelNetworkRequest = async (cancelTokenUid) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('cancel-http-request', cancelTokenUid).then(resolve).catch(reject); ipcRenderer.invoke('cancel-http-request', cancelTokenUid).then(resolve).catch(reject);
}); });
}; };

View File

@ -162,9 +162,7 @@ const getCollectionRoot = (dir) => {
} }
const content = fs.readFileSync(collectionRootPath, 'utf8'); const content = fs.readFileSync(collectionRootPath, 'utf8');
const json = collectionBruToJson(content); return collectionBruToJson(content);
return json;
}; };
const builder = async (yargs) => { const builder = async (yargs) => {

View File

@ -88,6 +88,13 @@ const prepareRequest = (request, collectionRoot) => {
axiosRequest.data = request.body.xml; axiosRequest.data = request.body.xml;
} }
if (request.body.mode === 'sparql') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/sparql-query';
}
axiosRequest.data = request.body.sparql;
}
if (request.body.mode === 'formUrlEncoded') { if (request.body.mode === 'formUrlEncoded') {
axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded'; axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';
const params = {}; const params = {};

View File

@ -16,6 +16,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance'); const { makeAxiosInstance } = require('../utils/axios-instance');
const { shouldUseProxy } = require('../utils/proxy-util');
const runSingleRequest = async function ( const runSingleRequest = async function (
filename, filename,
@ -47,7 +48,7 @@ const runSingleRequest = async function (
// run pre-request vars // run pre-request vars
const preRequestVars = get(bruJson, 'request.vars.req'); const preRequestVars = get(bruJson, 'request.vars.req');
if (preRequestVars && preRequestVars.length) { if (preRequestVars?.length) {
const varsRuntime = new VarsRuntime(); const varsRuntime = new VarsRuntime();
varsRuntime.runPreRequestVars( varsRuntime.runPreRequestVars(
preRequestVars, preRequestVars,
@ -64,7 +65,7 @@ const runSingleRequest = async function (
get(collectionRoot, 'request.script.req'), get(collectionRoot, 'request.script.req'),
get(bruJson, 'request.script.req') get(bruJson, 'request.script.req')
]).join(os.EOL); ]).join(os.EOL);
if (requestScriptFile && requestScriptFile.length) { if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runRequestScript( await scriptRuntime.runRequestScript(
decomment(requestScriptFile), decomment(requestScriptFile),
@ -87,36 +88,56 @@ const runSingleRequest = async function (
if (insecure) { if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false; httpsAgentRequestFields['rejectUnauthorized'] = false;
} else { } else {
const cacertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS]; const caCertArray = [options['cacert'], process.env.SSL_CERT_FILE, process.env.NODE_EXTRA_CA_CERTS];
const cacert = cacertArray.find((el) => el); const caCert = caCertArray.find((el) => el);
if (cacert && cacert.length > 1) { if (caCert && caCert.length > 1) {
try { try {
caCrt = fs.readFileSync(cacert); httpsAgentRequestFields['ca'] = fs.readFileSync(caCert);
httpsAgentRequestFields['ca'] = caCrt;
} catch (err) { } catch (err) {
console.log('Error reading CA cert file:' + cacert, err); console.log('Error reading CA cert file:' + caCert, err);
} }
} }
} }
// set proxy if enabled
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
if (proxyEnabled) {
let proxyUri;
const interpolationOptions = { const interpolationOptions = {
envVars: envVariables, envVars: envVariables,
collectionVariables, collectionVariables,
processEnvVars processEnvVars
}; };
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert.domain, interpolationOptions);
const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions);
const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions);
if (domain && certFilePath && keyFilePath) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
if (request.url.match(hostRegex)) {
try {
httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
} catch (err) {
console.log('Error reading cert/key file', err);
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
// set proxy if enabled
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
const shouldProxy = shouldUseProxy(request.url, get(brunoConfig, 'proxy.bypassProxy', ''));
if (proxyEnabled && shouldProxy) {
const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions);
const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions);
const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false);
const socksEnabled = proxyProtocol.includes('socks'); const socksEnabled = proxyProtocol.includes('socks');
interpolateString; let proxyUri;
if (proxyAuthEnabled) { if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions); const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions); const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions);
@ -128,16 +149,13 @@ const runSingleRequest = async function (
if (socksEnabled) { if (socksEnabled) {
const socksProxyAgent = new SocksProxyAgent(proxyUri); const socksProxyAgent = new SocksProxyAgent(proxyUri);
request.httpsAgent = socksProxyAgent; request.httpsAgent = socksProxyAgent;
request.httpAgent = socksProxyAgent; request.httpAgent = socksProxyAgent;
} else { } else {
request.httpsAgent = new HttpsProxyAgent( request.httpsAgent = new HttpsProxyAgent(
proxyUri, proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
); );
request.httpAgent = new HttpProxyAgent(proxyUri); request.httpAgent = new HttpProxyAgent(proxyUri);
} }
} else if (Object.keys(httpsAgentRequestFields).length > 0) { } else if (Object.keys(httpsAgentRequestFields).length > 0) {
@ -163,7 +181,7 @@ const runSingleRequest = async function (
responseTime = response.headers.get('request-duration'); responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration'); response.headers.delete('request-duration');
} catch (err) { } catch (err) {
if (err && err.response) { if (err?.response) {
response = err.response; response = err.response;
// Prevents the duration on leaking to the actual result // Prevents the duration on leaking to the actual result
@ -199,7 +217,7 @@ const runSingleRequest = async function (
// run post-response vars // run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res'); const postResponseVars = get(bruJson, 'request.vars.res');
if (postResponseVars && postResponseVars.length) { if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime(); const varsRuntime = new VarsRuntime();
varsRuntime.runPostResponseVars( varsRuntime.runPostResponseVars(
postResponseVars, postResponseVars,
@ -217,7 +235,7 @@ const runSingleRequest = async function (
get(collectionRoot, 'request.script.res'), get(collectionRoot, 'request.script.res'),
get(bruJson, 'request.script.res') get(bruJson, 'request.script.res')
]).join(os.EOL); ]).join(os.EOL);
if (responseScriptFile && responseScriptFile.length) { if (responseScriptFile?.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
await scriptRuntime.runResponseScript( await scriptRuntime.runResponseScript(
decomment(responseScriptFile), decomment(responseScriptFile),
@ -275,7 +293,7 @@ const runSingleRequest = async function (
testResults = get(result, 'results', []); testResults = get(result, 'results', []);
} }
if (testResults && testResults.length) { if (testResults?.length) {
each(testResults, (testResult) => { each(testResults, (testResult) => {
if (testResult.status === 'pass') { if (testResult.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(testResult.description)); console.log(chalk.green(``) + chalk.dim(testResult.description));

View File

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

View File

@ -0,0 +1,66 @@
const parseUrl = require('url').parse;
const DEFAULT_PORTS = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443
};
/**
* check for proxy bypass, Copied form 'proxy-from-env'
*/
const shouldUseProxy = (url, proxyBypass) => {
if (proxyBypass === '*') {
return false; // Never proxy if wildcard is set.
}
// use proxy if no proxyBypass is set
if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) {
return true;
}
const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {};
let proto = parsedUrl.protocol;
let hostname = parsedUrl.host;
let port = parsedUrl.port;
if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') {
return false; // Don't proxy URLs without a valid scheme or host.
}
proto = proto.split(':', 1)[0];
// Stripping ports in this way instead of using parsedUrl.hostname to make
// sure that the brackets around IPv6 addresses are kept.
hostname = hostname.replace(/:\d*$/, '');
port = parseInt(port) || DEFAULT_PORTS[proto] || 0;
return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) {
if (!dontProxyFor) {
return true; // Skip zero-length hosts.
}
const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/);
let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor;
const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0;
if (parsedProxyPort && parsedProxyPort !== port) {
return true; // Skip if ports don't match.
}
if (!/^[.*]/.test(parsedProxyHostname)) {
// No wildcards, so stop proxying if there is an exact match.
return hostname !== parsedProxyHostname;
}
if (parsedProxyHostname.charAt(0) === '*') {
// Remove leading wildcard.
parsedProxyHostname = parsedProxyHostname.slice(1);
}
// Stop proxying if the hostname ends with the no_proxy host.
return !hostname.endsWith(parsedProxyHostname);
});
};
module.exports = {
shouldUseProxy
};

View File

@ -44,7 +44,7 @@ const template = [
}, },
{ {
role: 'window', role: 'window',
submenu: [{ role: 'minimize' }, { role: 'close' }] submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }]
}, },
{ {
role: 'help', role: 'help',

View File

@ -75,7 +75,7 @@ app.on('ready', async () => {
}); });
// register all ipc handlers // register all ipc handlers
registerNetworkIpc(mainWindow, watcher, lastOpenedCollections); registerNetworkIpc(mainWindow);
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
}); });

View File

@ -32,9 +32,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// browse directory // browse directory
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => { ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
try { try {
const dirPath = await browseDirectory(mainWindow); return await browseDirectory(mainWindow);
return dirPath;
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);
} }
@ -67,8 +65,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid);
return;
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);
} }
@ -93,8 +89,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
collectionPathname, collectionPathname,
newName newName
}); });
return;
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);
} }
@ -311,7 +305,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.unlinkSync(pathname); fs.unlinkSync(pathname);
} else { } else {
return Promise.reject(error); return Promise.reject();
} }
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);

View File

@ -5,7 +5,7 @@ function isStrPresent(str) {
return str && str !== '' && str !== 'undefined'; return str && str !== '' && str !== 'undefined';
} }
async function resolveCredentials(request) { async function resolveAwsV4Credentials(request) {
const awsv4 = request.awsv4config; const awsv4 = request.awsv4config;
if (isStrPresent(awsv4.profileName)) { if (isStrPresent(awsv4.profileName)) {
try { try {
@ -52,5 +52,5 @@ function addAwsV4Interceptor(axiosInstance, request) {
module.exports = { module.exports = {
addAwsV4Interceptor, addAwsV4Interceptor,
resolveCredentials resolveAwsV4Credentials
}; };

View File

@ -4,10 +4,10 @@ const axios = require('axios');
* Function that configures axios with timing interceptors * Function that configures axios with timing interceptors
* Important to note here that the timings are not completely accurate. * Important to note here that the timings are not completely accurate.
* @see https://github.com/axios/axios/issues/695 * @see https://github.com/axios/axios/issues/695
* @returns {import('axios').AxiosStatic} * @returns {axios.AxiosInstance}
*/ */
function makeAxiosInstance() { function makeAxiosInstance() {
/** @type {import('axios').AxiosStatic} */ /** @type {axios.AxiosInstance} */
const instance = axios.create(); const instance = axios.create();
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {

View File

@ -16,14 +16,15 @@ const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars'); const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string'); const { interpolateString } = require('./interpolate-string');
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper'); const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
const { getPreferences } = require('../../store/preferences'); const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env'); const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config'); const { getBrunoConfig } = require('../../store/bruno-config');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent'); const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('./axios-instance'); const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveCredentials } = require('./awsv4auth-helper'); const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy } = require('../../utils/proxy-util');
// override the default escape function to prevent escaping // override the default escape function to prevent escaping
Mustache.escape = function (value) { Mustache.escape = function (value) {
@ -83,6 +84,96 @@ const getSize = (data) => {
return 0; return 0;
}; };
const configureRequest = async (collectionUid, request, envVars, collectionVariables, processEnvVars) => {
const httpsAgentRequestFields = {};
if (!preferencesUtil.shouldVerifyTls()) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
const brunoConfig = getBrunoConfig(collectionUid);
const interpolationOptions = {
envVars,
collectionVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
const domain = interpolateString(clientCert.domain, interpolationOptions);
const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions);
const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions);
if (domain && certFilePath && keyFilePath) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
if (request.url.match(hostRegex)) {
try {
httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
} catch (err) {
console.log('Error reading cert/key file', err);
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
// proxy configuration
let proxyConfig = get(brunoConfig, 'proxy', {});
let proxyEnabled = get(proxyConfig, 'enabled', 'disabled');
if (proxyEnabled === 'global') {
proxyConfig = preferencesUtil.getGlobalProxyConfig();
proxyEnabled = get(proxyConfig, 'enabled', false);
}
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (proxyEnabled === true && shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`;
}
if (socksEnabled) {
const socksProxyAgent = new SocksProxyAgent(proxyUri);
request.httpsAgent = socksProxyAgent;
request.httpAgent = socksProxyAgent;
} else {
request.httpsAgent = new HttpsProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
request.httpAgent = new HttpProxyAgent(proxyUri);
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
const axiosInstance = makeAxiosInstance();
if (request.awsv4config) {
request.awsv4config = await resolveAwsV4Credentials(request);
addAwsV4Interceptor(axiosInstance, request);
delete request.awsv4config;
}
request.timeout = preferencesUtil.getRequestTimeout();
return axiosInstance;
};
const registerNetworkIpc = (mainWindow) => { const registerNetworkIpc = (mainWindow) => {
// handler for sending http request // handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => { ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => {
@ -134,7 +225,7 @@ const registerNetworkIpc = (mainWindow) => {
// run pre-request vars // run pre-request vars
const preRequestVars = get(request, 'vars.req', []); const preRequestVars = get(request, 'vars.req', []);
if (preRequestVars && preRequestVars.length) { if (preRequestVars?.length) {
const varsRuntime = new VarsRuntime(); const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPreRequestVars( const result = varsRuntime.runPreRequestVars(
preRequestVars, preRequestVars,
@ -155,15 +246,11 @@ const registerNetworkIpc = (mainWindow) => {
} }
} }
const preferences = getPreferences();
const timeout = get(preferences, 'request.timeout', 0);
request.timeout = timeout;
// run pre-request script // run pre-request script
const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(
os.EOL os.EOL
); );
if (requestScript && requestScript.length) { if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runRequestScript( const result = await scriptRuntime.runRequestScript(
decomment(requestScript), decomment(requestScript),
@ -209,96 +296,49 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid cancelTokenUid
}); });
const sslVerification = get(preferences, 'request.sslVerification', true); const axiosInstance = await configureRequest(
const httpsAgentRequestFields = {}; collectionUid,
if (!sslVerification) { request,
httpsAgentRequestFields['rejectUnauthorized'] = false;
}
const brunoConfig = getBrunoConfig(collectionUid);
const interpolationOptions = {
envVars, envVars,
collectionVariables, collectionVariables,
processEnvVars processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (clientCert of clientCertConfig) {
const domain = interpolateString(clientCert.domain, interpolationOptions);
const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions);
const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions);
if (domain && certFilePath && keyFilePath) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
if (request.url.match(hostRegex)) {
try {
httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
} catch (err) {
console.log('Error reading cert/key file', err);
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
// proxy configuration
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
if (proxyEnabled) {
let proxyUri;
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);
const socksEnabled = proxyProtocol.includes('socks');
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions);
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`;
}
if (socksEnabled) {
const socksProxyAgent = new SocksProxyAgent(proxyUri);
request.httpsAgent = socksProxyAgent;
request.httpAgent = socksProxyAgent;
} else {
request.httpsAgent = new HttpsProxyAgent(
proxyUri,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
); );
request.httpAgent = new HttpProxyAgent(proxyUri); let response, responseTime;
} try {
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
const axiosInstance = makeAxiosInstance();
if (request.awsv4config) {
request.awsv4config = await resolveCredentials(request);
addAwsV4Interceptor(axiosInstance, request);
delete request.awsv4config;
}
/** @type {import('axios').AxiosResponse} */ /** @type {import('axios').AxiosResponse} */
const response = await axiosInstance(request); 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 (error) {
deleteCancelToken(cancelTokenUid);
// if it's a cancel request, don't continue
if (axios.isCancel(error)) {
let error = new Error('Request cancelled');
error.isCancel = true;
return Promise.reject(error);
}
if (error?.response) {
response = error.response;
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
// if it's not a network error, don't continue
return Promise.reject(error);
}
}
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
// run post-response vars // run post-response vars
const postResponseVars = get(request, 'vars.res', []); const postResponseVars = get(request, 'vars.res', []);
if (postResponseVars && postResponseVars.length) { if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime(); const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPostResponseVars( const result = varsRuntime.runPostResponseVars(
postResponseVars, postResponseVars,
@ -324,7 +364,7 @@ const registerNetworkIpc = (mainWindow) => {
const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join( const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join(
os.EOL os.EOL
); );
if (responseScript && responseScript.length) { if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runResponseScript( const result = await scriptRuntime.runResponseScript(
decomment(responseScript), decomment(responseScript),
@ -403,100 +443,16 @@ const registerNetworkIpc = (mainWindow) => {
}); });
} }
deleteCancelToken(cancelTokenUid);
// Prevents the duration on leaking to the actual result
const requestDuration = response.headers.get('request-duration');
response.headers.delete('request-duration');
return { return {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: response.headers,
data: response.data, data: response.data,
duration: requestDuration duration: responseTime ?? 0
}; };
} catch (error) { } catch (error) {
// todo: better error handling
// need to convey the error to the UI
// and need not be always a network error
deleteCancelToken(cancelTokenUid); deleteCancelToken(cancelTokenUid);
if (axios.isCancel(error)) {
let error = new Error('Request cancelled');
error.isCancel = true;
return Promise.reject(error);
}
if (error && error.response) {
// run assertions
const assertions = get(request, 'assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
request,
error.response,
envVars,
collectionVariables,
collectionPath
);
mainWindow.webContents.send('main:run-request-event', {
type: 'assertion-results',
results: results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
// run tests
const testFile = compact([
get(collectionRoot, 'request.tests'),
item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
]).join(os.EOL);
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
error.response,
envVars,
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
);
mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
results: testResults.results,
itemUid: item.uid,
requestUid,
collectionUid
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
collectionVariables: testResults.collectionVariables,
requestUid,
collectionUid
});
}
// Prevents the duration from leaking to the actual result
const requestDuration = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
duration: requestDuration ?? 0
};
}
return Promise.reject(error); return Promise.reject(error);
} }
}); });
@ -519,12 +475,9 @@ const registerNetworkIpc = (mainWindow) => {
const collectionRoot = get(collection, 'root', {}); const collectionRoot = get(collection, 'root', {});
const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot); const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot);
const preferences = getPreferences(); request.timeout = preferencesUtil.getRequestTimeout();
const timeout = get(preferences, 'request.timeout', 0);
request.timeout = timeout;
const sslVerification = get(preferences, 'request.sslVerification', true);
if (!sslVerification) { if (!preferencesUtil.shouldVerifyTls()) {
request.httpsAgent = new https.Agent({ request.httpsAgent = new https.Agent({
rejectUnauthorized: false rejectUnauthorized: false
}); });
@ -533,7 +486,14 @@ const registerNetworkIpc = (mainWindow) => {
const processEnvVars = getProcessEnvVars(collection.uid); const processEnvVars = getProcessEnvVars(collection.uid);
interpolateVars(preparedRequest, envVars, collection.collectionVariables, processEnvVars); interpolateVars(preparedRequest, envVars, collection.collectionVariables, processEnvVars);
const response = await axios(preparedRequest); const axiosInstance = await configureRequest(
collection.uid,
preparedRequest,
envVars,
collection.collectionVariables,
processEnvVars
);
const response = await axiosInstance(preparedRequest);
return { return {
status: response.status, status: response.status,
@ -658,16 +618,11 @@ const registerNetworkIpc = (mainWindow) => {
} }
} }
const preferences = getPreferences();
const timeout = get(preferences, 'request.timeout', 0);
request.timeout = timeout;
const sslVerification = get(preferences, 'request.sslVerification', true);
// run pre-request script // run pre-request script
const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join( const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(
os.EOL os.EOL
); );
if (requestScript && requestScript.length) { if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime(); const scriptRuntime = new ScriptRuntime();
const result = await scriptRuntime.runRequestScript( const result = await scriptRuntime.runRequestScript(
decomment(requestScript), decomment(requestScript),
@ -704,65 +659,61 @@ const registerNetworkIpc = (mainWindow) => {
...eventData ...eventData
}); });
// proxy configuration const axiosInstance = await configureRequest(
const brunoConfig = getBrunoConfig(collectionUid); collectionUid,
const proxyEnabled = get(brunoConfig, 'proxy.enabled', false); request,
if (proxyEnabled) {
let proxyUri;
const interpolationOptions = {
envVars, envVars,
collectionVariables, collectionVariables,
processEnvVars processEnvVars
);
timeStart = Date.now();
let response;
try {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
timeEnd = Date.now();
mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received',
responseReceived: {
status: response.status,
statusText: response.statusText,
headers: Object.entries(response.headers),
duration: timeEnd - timeStart,
size: response.headers['content-length'] || getSize(response.data),
data: response.data
},
...eventData
});
} catch (error) {
if (error?.response) {
timeEnd = Date.now();
response = {
status: error.response.status,
statusText: error.response.statusText,
headers: Object.entries(error.response.headers),
duration: timeEnd - timeStart,
size: error.response.headers['content-length'] || getSize(error.response.data),
data: error.response.data
}; };
const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions); // if we get a response from the server, we consider it as a success
const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions); mainWindow.webContents.send('main:run-folder-event', {
const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions); type: 'response-received',
const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false); error: error ? error.message : 'An error occurred while running the request',
const socksEnabled = proxyProtocol.includes('socks'); responseReceived: response,
...eventData
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(
get(brunoConfig, 'proxy.auth.username'),
interpolationOptions
);
const proxyAuthPassword = interpolateString(
get(brunoConfig, 'proxy.auth.password'),
interpolationOptions
);
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}:${proxyPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}:${proxyPort}`;
}
if (socksEnabled) {
const socksProxyAgent = new SocksProxyAgent(proxyUri);
request.httpsAgent = socksProxyAgent;
request.httpAgent = socksProxyAgent;
} else {
request.httpsAgent = new HttpsProxyAgent(proxyUri, {
rejectUnauthorized: sslVerification
}); });
} else {
request.httpAgent = new HttpProxyAgent(proxyUri); // if it's not a network error, don't continue
throw Promise.reject(error);
} }
} else if (!sslVerification) {
request.httpsAgent = new https.Agent({
rejectUnauthorized: false
});
} }
// send request
timeStart = Date.now();
const response = await axios(request);
timeEnd = Date.now();
// run post-response vars // run post-response vars
const postResponseVars = get(request, 'vars.res', []); const postResponseVars = get(request, 'vars.res', []);
if (postResponseVars && postResponseVars.length) { if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime(); const varsRuntime = new VarsRuntime();
const result = varsRuntime.runPostResponseVars( const result = varsRuntime.runPostResponseVars(
postResponseVars, postResponseVars,
@ -861,105 +812,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid collectionUid
}); });
} }
mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received',
...eventData,
responseReceived: {
status: response.status,
statusText: response.statusText,
headers: Object.entries(response.headers),
duration: timeEnd - timeStart,
size: response.headers['content-length'] || getSize(response.data),
data: response.data
}
});
} catch (error) { } catch (error) {
let responseReceived = {};
let duration = 0;
if (timeStart && timeEnd) {
duration = timeEnd - timeStart;
}
if (error && error.response) {
responseReceived = {
status: error.response.status,
statusText: error.response.statusText,
headers: Object.entries(error.response.headers),
duration: duration,
size: error.response.headers['content-length'] || getSize(error.response.data),
data: error.response.data
};
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime();
const results = assertRuntime.runAssertions(
assertions,
request,
error.response,
envVars,
collectionVariables,
collectionPath
);
mainWindow.webContents.send('main:run-folder-event', {
type: 'assertion-results',
assertionResults: results,
itemUid: item.uid,
collectionUid
});
}
// run tests
const testFile = compact([
get(collectionRoot, 'request.tests'),
item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
]).join(os.EOL);
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime();
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
error.response,
envVars,
collectionVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig
);
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: testResults.results,
...eventData
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
collectionVariables: testResults.collectionVariables,
collectionUid
});
}
// if we get a response from the server, we consider it as a success
mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received',
error: error ? error.message : 'An error occurred while running the request',
responseReceived: responseReceived,
...eventData
});
continue;
}
mainWindow.webContents.send('main:run-folder-event', { mainWindow.webContents.send('main:run-folder-event', {
type: 'error', type: 'error',
error: error ? error.message : 'An error occurred while running the request', error: error ? error.message : 'An error occurred while running the request',
responseReceived: responseReceived, responseReceived: {},
...eventData ...eventData
}); });
} }

View File

@ -2,7 +2,7 @@ const { ipcMain } = require('electron');
const { getPreferences, savePreferences } = require('../store/preferences'); const { getPreferences, savePreferences } = require('../store/preferences');
const { isDirectory } = require('../utils/filesystem'); const { isDirectory } = require('../utils/filesystem');
const { openCollection } = require('../app/collections'); const { openCollection } = require('../app/collections');
``;
const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
ipcMain.handle('renderer:ready', async (event) => { ipcMain.handle('renderer:ready', async (event) => {
// load preferences // load preferences
@ -15,7 +15,7 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
if (lastOpened && lastOpened.length) { if (lastOpened && lastOpened.length) {
for (let collectionPath of lastOpened) { for (let collectionPath of lastOpened) {
if (isDirectory(collectionPath)) { if (isDirectory(collectionPath)) {
openCollection(mainWindow, watcher, collectionPath, { await openCollection(mainWindow, watcher, collectionPath, {
dontSendDisplayErrors: true dontSendDisplayErrors: true
}); });
} }

View File

@ -1,5 +1,12 @@
const Yup = require('yup'); const Yup = require('yup');
const Store = require('electron-store'); const Store = require('electron-store');
const { get } = require('lodash');
/**
* The preferences are stored in the electron store 'preferences.json'.
* The electron process uses this module to get the preferences.
*
*/
const defaultPreferences = { const defaultPreferences = {
request: { request: {
@ -8,6 +15,18 @@ const defaultPreferences = {
}, },
font: { font: {
codeFont: 'default' codeFont: 'default'
},
proxy: {
enabled: false,
protocol: 'http',
hostname: '',
port: '',
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
} }
}; };
@ -18,6 +37,18 @@ const preferencesSchema = Yup.object().shape({
}), }),
font: Yup.object().shape({ font: Yup.object().shape({
codeFont: Yup.string().nullable() codeFont: Yup.string().nullable()
}),
proxy: Yup.object({
enabled: Yup.boolean(),
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
port: Yup.number().min(1).max(65535).nullable(),
auth: Yup.object({
enabled: Yup.boolean(),
username: Yup.string().max(1024),
password: Yup.string().max(1024)
}).optional(),
bypassProxy: Yup.string().optional().max(1024)
}) })
}); });
@ -61,7 +92,20 @@ const savePreferences = async (newPreferences) => {
}); });
}; };
const preferencesUtil = {
shouldVerifyTls: () => {
return get(getPreferences(), 'request.sslVerification', true);
},
getRequestTimeout: () => {
return get(getPreferences(), 'request.timeout', 0);
},
getGlobalProxyConfig: () => {
return get(getPreferences(), 'proxy', {});
}
};
module.exports = { module.exports = {
getPreferences, getPreferences,
savePreferences savePreferences,
preferencesUtil
}; };

View File

@ -0,0 +1,66 @@
const parseUrl = require('url').parse;
const { isEmpty } = require('lodash');
const DEFAULT_PORTS = {
ftp: 21,
gopher: 70,
http: 80,
https: 443,
ws: 80,
wss: 443
};
/**
* check for proxy bypass, copied form 'proxy-from-env'
*/
const shouldUseProxy = (url, proxyBypass) => {
if (proxyBypass === '*') {
return false; // Never proxy if wildcard is set.
}
// use proxy if no proxyBypass is set
if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) {
return true;
}
const parsedUrl = typeof url === 'string' ? parseUrl(url) : url || {};
let proto = parsedUrl.protocol;
let hostname = parsedUrl.host;
let port = parsedUrl.port;
if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') {
return false; // Don't proxy URLs without a valid scheme or host.
}
proto = proto.split(':', 1)[0];
// Stripping ports in this way instead of using parsedUrl.hostname to make
// sure that the brackets around IPv6 addresses are kept.
hostname = hostname.replace(/:\d*$/, '');
port = parseInt(port) || DEFAULT_PORTS[proto] || 0;
return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) {
if (!dontProxyFor) {
return true; // Skip zero-length hosts.
}
const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/);
let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor;
const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2]) : 0;
if (parsedProxyPort && parsedProxyPort !== port) {
return true; // Skip if ports don't match.
}
if (!/^[.*]/.test(parsedProxyHostname)) {
// No wildcards, so stop proxying if there is an exact match.
return hostname !== parsedProxyHostname;
}
if (parsedProxyHostname.charAt(0) === '*') {
// Remove leading wildcard.
parsedProxyHostname = parsedProxyHostname.slice(1);
}
// Stop proxying if the hostname ends with the no_proxy host.
return !hostname.endsWith(parsedProxyHostname);
});
};
module.exports = {
shouldUseProxy
};

View File

@ -0,0 +1,50 @@
const { shouldUseProxy } = require('../../src/utils/proxy-util');
test('no proxy necessary - star', () => {
const url = 'http://wwww.example.org/test';
const noProxy = '*';
expect(shouldUseProxy(url, noProxy)).toEqual(false);
});
test('no proxy necessary - no noProxy bypass', () => {
const url = 'http://wwww.example.org/test';
const noProxy = '';
expect(shouldUseProxy(url, noProxy)).toEqual(true);
});
test('no proxy necessary - wildcard match', () => {
const url = 'http://wwww.example.org/test';
const noProxy = '*example.org';
expect(shouldUseProxy(url, noProxy)).toEqual(false);
});
test('no proxy necessary - direct proxy', () => {
const url = 'http://wwww.example.org/test';
const noProxy = 'wwww.example.org';
expect(shouldUseProxy(url, noProxy)).toEqual(false);
});
test('no proxy necessary - multiple proxy', () => {
const url = 'http://wwww.example.org/test';
const noProxy = 'www.example.com,wwww.example.org';
expect(shouldUseProxy(url, noProxy)).toEqual(false);
});
test('proxy necessary - no proxy match multiple', () => {
const url = 'https://wwww.example.test/test';
const noProxy = 'www.example.com,wwww.example.org';
expect(shouldUseProxy(url, noProxy)).toEqual(true);
});
test('proxy necessary - no proxy match', () => {
const url = 'https://wwww.example.test/test';
const noProxy = 'www.example.com';
expect(shouldUseProxy(url, noProxy)).toEqual(true);
});

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

View File

@ -4,6 +4,7 @@ const { nanoid } = require('nanoid');
const Bru = require('../bru'); const Bru = require('../bru');
const BrunoRequest = require('../bruno-request'); const BrunoRequest = require('../bruno-request');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils'); const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
const { interpolateString } = require('../interpolate-string');
const { expect } = chai; const { expect } = chai;
chai.use(require('chai-string')); chai.use(require('chai-string'));
@ -161,17 +162,27 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return; return;
} }
const interpolationContext = {
collectionVariables: context.bru.collectionVariables,
envVariables: context.bru.envVariables,
processEnvVars: context.bru.processEnvVars
};
// gracefully allow both a,b as well as [a, b] // gracefully allow both a,b as well as [a, b]
if (operator === 'in' || operator === 'notIn') { if (operator === 'in' || operator === 'notIn') {
if (rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) { if (rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) {
rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1); rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
} }
return rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context)); return rhsOperand
.split(',')
.map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
} }
if (operator === 'between') { if (operator === 'between') {
const [lhs, rhs] = rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context)); const [lhs, rhs] = rhsOperand
.split(',')
.map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
return [lhs, rhs]; return [lhs, rhs];
} }
@ -181,10 +192,10 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1); rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
} }
return rhsOperand; return interpolateString(rhsOperand, interpolationContext);
} }
return evaluateJsTemplateLiteral(rhsOperand, context); return evaluateJsTemplateLiteral(interpolateString(rhsOperand, interpolationContext), context);
}; };
class AssertRuntime { class AssertRuntime {

View File

@ -10,7 +10,7 @@
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) [![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads) [![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
**English** | [Русский](/readme_ru.md) **English** | [Українська](/readme_ua.md) | [Русский](/readme_ru.md)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.

View File

@ -10,7 +10,7 @@
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) [![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads) [![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](/readme.md) | **Русский** [English](/readme.md) | [Українська](/readme_ua.md) | **Русский**
Bruno - новый и инновационный клиент API, направленный на революцию в установившейся ситуации, представленной Postman и подобными инструментами. Bruno - новый и инновационный клиент API, направленный на революцию в установившейся ситуации, представленной Postman и подобными инструментами.

80
readme_ua.md Normal file
View File

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

View File

@ -1,8 +1,7 @@
const os = require('os'); const os = require('os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const util = require('util'); const util = require('util');
const exec = util.promisify(require('child_process').exec); const spawn = util.promisify(require('child_process').spawn);
async function deleteFileIfExists(filePath) { async function deleteFileIfExists(filePath) {
try { try {
@ -47,6 +46,25 @@ async function removeSourceMapFiles(directory) {
} }
} }
async function execCommandWithOutput(command) {
return new Promise(async (resolve, reject) => {
const childProcess = await spawn(command, {
stdio: 'inherit',
shell: true
});
childProcess.on('error', (error) => {
reject(error);
});
childProcess.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command exited with code ${code}.`));
}
});
});
}
async function main() { async function main() {
try { try {
// Remove out directory // Remove out directory
@ -67,13 +85,13 @@ async function main() {
for (const file of files) { for (const file of files) {
if (file.endsWith('.html')) { if (file.endsWith('.html')) {
let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8'); let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8');
content = content.replace(/\/_next\//g, '/_next/'); content = content.replace(/\/_next\//g, '_next/');
await fs.writeFile(`packages/bruno-electron/web/${file}`, content); await fs.writeFile(`packages/bruno-electron/web/${file}`, content);
} }
} }
// Remove sourcemaps // Remove sourcemaps
await removeSourceMapFiles('packages/bruno-electron/web') await removeSourceMapFiles('packages/bruno-electron/web');
// Run npm dist command // Run npm dist command
console.log('Building the Electron distribution'); console.log('Building the Electron distribution');
@ -88,8 +106,7 @@ async function main() {
osArg = 'linux'; osArg = 'linux';
} }
await exec(`npm run dist-${osArg} --workspace=packages/bruno-electron`); await execCommandWithOutput(`npm run dist:${osArg} --workspace=packages/bruno-electron`);
} catch (error) { } catch (error) {
console.error('An error occurred:', error); console.error('An error occurred:', error);
} }

View File

@ -1,6 +1,6 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { HomePage } = require('../tests/pages/home.page'); const { HomePage } = require('../tests/pages/home.page');
import * as faker from './utils/data-faker'; const { faker } = require('./utils/data-faker');
test.describe('bruno e2e test', () => { test.describe('bruno e2e test', () => {
let homePage; let homePage;