Compare commits

...

22 Commits

Author SHA1 Message Date
Anoop M D
c9cabfde35 release: bumped version 2022-10-30 20:07:11 +05:30
Anoop M D
fde15d7c31 Merge branch 'main' of github.com:usebruno/bruno into main 2022-10-30 19:58:06 +05:30
Anoop M D
11defe18ca chore: disable telemetry during playwright test execution 2022-10-30 19:56:54 +05:30
anusreesubash
54b14a005d fix(#53): fix for response editor search issue (#55) 2022-10-30 17:50:38 +05:30
Anoop M D
f283df2a1b feat: posthog telemetry 2022-10-30 17:48:36 +05:30
Anoop M D
820c99711b fix: fixed missing sidebar bg color 2022-10-30 04:05:52 +05:30
Anoop M D
df1cd4aff9 Merge branch 'feature/import-postman-collection' 2022-10-30 02:01:22 +05:30
Anoop M D
481486cd1c feat: import postman collection (#45) 2022-10-30 02:00:54 +05:30
Anoop M D
bf4c26de33 feat: refactored import collection 2022-10-30 00:09:24 +05:30
depa panjie purnama
c3fa473dae use fakerjs as random test data (#51) 2022-10-27 23:23:04 +05:30
depa panjie purnama
90a29918d0 feat: Create Collection e2e test (#50)
* add selector IDs
* add Create Collection e2e test
2022-10-26 21:59:37 +05:30
Anoop M D
c0698adcb3 chore: cleanup 2022-10-25 14:58:57 +05:30
Anoop M D
0d0f99e810 chore: cleanup 2022-10-25 14:57:53 +05:30
Anoop M D
7f5a6d5566 chore: using npm i instead of ci in playwright github actions 2022-10-25 00:52:42 +05:30
depa panjie purnama
dc68d511bd add e2e test using playwright (#44) 2022-10-25 00:42:53 +05:30
Anoop M D
0fceaf6918 chore: updated readme 2022-10-23 22:40:26 +05:30
Anoop M D
831223711a chore: updated app description 2022-10-23 20:57:35 +05:30
Anoop M D
e4cf3750bd chore: fixed missing screenshote in readme 2022-10-23 20:51:12 +05:30
Anoop M D
01e15b7fc1 Merge branch 'main' of github.com:usebruno/bruno 2022-10-23 19:48:33 +05:30
Anoop M D
3bf3d30ce8 fix: environment var interpolation issues 2022-10-23 19:48:02 +05:30
Vijay Hudge
bf6e6b29f5 Updated Readme with all contributors shown as image (#43) 2022-10-23 17:17:45 +05:30
Sean
075aaaebec Fixed spelling error in contributing.md and updated pull request section (#41) 2022-10-23 13:50:42 +05:30
38 changed files with 765 additions and 390 deletions

27
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Install dependencies
run: npm i
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

4
.gitignore vendored
View File

@@ -17,6 +17,7 @@ chrome-extension
chrome-extension.pem
chrome-extension.crx
bruno.zip
*.zip
# misc
.DS_Store
@@ -37,3 +38,6 @@ yarn-error.log*
/renderer
/renderer/.next/
/renderer/out/
/test-results/
/playwright-report/
/playwright/.cache/

BIN
assets/images/landing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

View File

@@ -1,28 +1,31 @@
## Lets make bruno better, together !!
I am happy that you are looking to improve bruno. Below are the guidelines to get started bringing up bruno on your computed.
I am happy that you are looking to improve bruno. Below are the guidelines to get started bringing up bruno on your computer.
### Technology Stack
Bruno is built using NextJs and React. We also use electron to ship a desktop version (that supports local collections)
Libraries we use
* CSS - Tailwind
* Code Editors - Codemirror
* State Management - Redux
* Icons - Tabler Icons
* Forms - formik
* Schema Validation - Yup
* Request Client - axios
* Filesystem Watcher - chokidar
Libraries we use
- CSS - Tailwind
- Code Editors - Codemirror
- State Management - Redux
- Icons - Tabler Icons
- Forms - formik
- Schema Validation - Yup
- Request Client - axios
- Filesystem Watcher - chokidar
### Dependencies
You would need Node v14.x and npm 8.x. We use npm workspaces in the project
You would need [Node v14.x or the latest LTS version](https://nodejs.org/en/) and npm 8.x. We use npm workspaces in the project
### Lets start coding
```bash
# clone and cd into bruno
# use Node 14.x, Npm 8.x
# use Node 14.x, Npm 8.x
# Install deps (note that we use npm workspaces)
npm i
@@ -41,4 +44,10 @@ open http://localhost:3000
```
### Raising Pull Request
* Please keep the PR's small and focused on one thing
- Please keep the PR's small and focused on one thing
- Please follow the format of creating branches
- feature/[feature name]: This branch should contain changes for a specific feature
- Example: feature/dark-mode
- bugfix/[bug name]: This branch should container only bug fixes for a specific bug
- Example bugfix/bug-1

View File

@@ -1,55 +0,0 @@
const { ipcMain } = require('electron');
const template = [
{
label: 'Collection',
submenu: [
{
label: 'Open Collection',
click () {
ipcMain.emit('main:open-collection');
}
},
{ role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo'},
{ role: 'redo'},
{ role: 'separator'},
{ role: 'cut'},
{ role: 'copy'},
{ role: 'paste'}
]
},
{
label: 'View',
submenu: [
{ role: 'reload'},
{ role: 'toggledevtools'},
{ role: 'separator'},
{ role: 'resetzoom'},
{ role: 'zoomin'},
{ role: 'zoomout'},
{ role: 'separator'},
{ role: 'togglefullscreen'}
]
},
{
role: 'window',
submenu: [
{ role: 'minimize'},
{ role: 'close'}
]
},
{
role: 'help',
submenu: [
{ label: 'Learn More'}
]
}
];
module.exports = template;

View File

@@ -1,54 +0,0 @@
const path = require('path');
const { format } = require('url');
const { BrowserWindow, app, Menu } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
const menuTemplate = require('./app/menu-template');
const registerIpc = require('./ipc');
const isDev = require('electron-is-dev');
const prepareNext = require('electron-next');
setContentSecurityPolicy(`
default-src * 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * 'unsafe-inline';
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
`);
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);
let mainWindow;
// Prepare the renderer once the app is ready
app.on('ready', async () => {
await prepareNext('./renderer');
mainWindow = new BrowserWindow({
width: 1280,
height: 768,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, "preload.js")
},
});
const url = isDev
? 'http://localhost:8000'
: format({
pathname: path.join(__dirname, '../renderer/out/index.html'),
protocol: 'file:',
slashes: true
});
mainWindow.loadURL(url);
// register all ipc handlers
registerIpc(mainWindow);
});
// Quit the app once all windows are closed
app.on('window-all-closed', app.quit);

View File

@@ -1,47 +0,0 @@
const axios = require('axios');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { forOwn, extend } = require('lodash');
const registerIpc = () => {
// handler for sending http request
ipcMain.handle('send-http-request', async (event, request) => {
try {
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
if(request.headers && request.headers['content-type'] === 'multipart/form-data') {
const form = new FormData();
forOwn(request.data, (value, key) => {
form.append(key, value);
});
extend(request.headers, form.getHeaders());
request.data = form;
}
const result = await axios(request);
return {
status: result.status,
headers: result.headers,
data: result.data
};
} catch (error) {
if(error.response) {
return {
status: error.response.status,
headers: error.response.headers,
data: error.response.data
};
}
return {
status: -1,
headers: [],
data: null
};
}
});
};
module.exports = registerIpc;

View File

@@ -1,14 +0,0 @@
const { ipcRenderer, contextBridge } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
on: (channel, handler) => {
// Deliberately strip event as it includes `sender`
const subscription = (event, ...args) => handler(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
}
});

View File

@@ -1,14 +0,0 @@
const { customAlphabet } = require('nanoid');
// a customized version of nanoid without using _ and -
const uuid = () => {
// https://github.com/ai/nanoid/blob/main/url-alphabet/index.js
const urlAlphabet = 'useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict';
const customNanoId = customAlphabet (urlAlphabet, 21);
return customNanoId();
};
module.exports = {
uuid
};

View File

@@ -9,6 +9,8 @@
"packages/bruno-testbench"
],
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@playwright/test": "^1.27.1",
"jest": "^29.2.0",
"randomstring": "^1.2.2"
},
@@ -17,6 +19,8 @@
"build:web": "npm run build --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"build:chrome-extension": "./scripts/build-chrome-extension.sh",
"build:electron": "./scripts/build-electron.sh"
"build:electron": "./scripts/build-electron.sh",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report"
}
}

View File

@@ -1,5 +1,9 @@
module.exports = {
reactStrictMode: true,
publicRuntimeConfig: {
CI: process.env.CI,
PLAYWRIGHT: process.env.PLAYWRIGHT
},
webpack: (config, { isServer }) => {
// Fixes npm packages that depend on `fs` module
if (!isServer) {

View File

@@ -34,12 +34,15 @@
"nanoid": "3.3.4",
"next": "12.3.1",
"path": "^0.12.7",
"platform": "^1.3.6",
"posthog-node": "^2.1.0",
"qs": "^6.11.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hot-toast": "^2.4.0",
"react-redux": "^7.2.6",
"react-tabs": "^3.2.3",
"reckonjs": "^0.1.2",
"sass": "^1.46.0",
"split-on-first": "^3.0.0",
"styled-components": "^5.3.3",

View File

@@ -38,7 +38,7 @@ export default class QueryEditor extends React.Component {
showCursorWhenSelecting: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
readOnly: this.props.readOnly ? 'nocursor' : false,
readOnly: this.props.readOnly,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-Enter': () => {
@@ -61,6 +61,8 @@ export default class QueryEditor extends React.Component {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
Tab: function (cm) {
cm.replaceSelection(' ', 'end');
}

View File

@@ -144,6 +144,13 @@ const Wrapper = styled.div`
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
&.modal-footer-none {
.bruno-modal-content {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}
`;
export default Wrapper;

View File

@@ -64,6 +64,9 @@ const Modal = ({ size, title, confirmText, cancelText, handleCancel, handleConfi
if (isClosing) {
classes += ' modal--animate-out';
}
if(hideFooter) {
classes += ' modal-footer-none';
}
return (
<StyledWrapper className={classes}>
<div className={`bruno-modal-card modal-${size}`}>

View File

@@ -49,7 +49,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
spellCheck="false"
onChange={(event) => onUrlChange(event.target.value)}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" onClick={handleRun}>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<SendIcon color={theme.requestTabPanel.url.icon} width={22}/>
</div>
</div>

View File

@@ -82,7 +82,7 @@ const Collection = ({ collection, searchText }) => {
<div className="flex py-1 collection-name items-center">
<div className="flex flex-grow items-center" onClick={handleClick}>
<IconChevronRight size={16} strokeWidth={2} className={iconClassName} style={{ width: 16, color: 'rgb(160 160 160)' }} />
<div className="ml-1">{collection.name}</div>
<div className="ml-1" id="sidebar-collection-name">{collection.name}</div>
</div>
<div className="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { collectionImported } from 'providers/ReduxStore/slices/collections';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import { addCollectionToWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { toastError } from 'utils/common/error';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
const ImportCollection = ({ onClose }) => {
const dispatch = useDispatch();
const { activeWorkspaceUid } = useSelector((state) => state.workspaces);
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then((collection) => {
dispatch(collectionImported({ collection: collection }));
dispatch(addCollectionToWorkspace(activeWorkspaceUid, collection.uid));
toast.success('Collection imported successfully');
onClose();
})
.catch((err) => toastError(err, 'Import collection failed'));
};
const handleImportPostmanCollection = () => {
importPostmanCollection()
.then((collection) => {
dispatch(collectionImported({ collection: collection }));
dispatch(addCollectionToWorkspace(activeWorkspaceUid, collection.uid));
toast.success('Postman Collection imported successfully');
onClose();
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
<div
className='text-link hover:underline cursor-pointer'
onClick={handleImportBrunoCollection}
>
Bruno Collection
</div>
<div
className='text-link hover:underline cursor-pointer mt-2'
onClick={handleImportPostmanCollection}
>
Postman Collection
</div>
</div>
</Modal>
);
};
export default ImportCollection;

View File

@@ -4,7 +4,7 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.sidebar.color};
aside {
background-color: ${(props) => props.theme['sidebar-background']};
background-color: ${(props) => props.theme.sidebar.bg};
.collection-title {
line-height: 1.5;

View File

@@ -2,8 +2,8 @@ import toast from 'react-hot-toast';
import Bruno from 'components/Bruno';
import Dropdown from 'components/Dropdown';
import CreateCollection from '../CreateCollection';
import importCollection from 'utils/collections/import';
import SelectCollection from 'components/Sidebar/Collections/SelectCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import { IconDots } from '@tabler/icons';
import { IconFolders } from '@tabler/icons';
@@ -11,13 +11,13 @@ import { isElectron } from 'utils/common/platform';
import { useState, forwardRef, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { collectionImported } from 'providers/ReduxStore/slices/collections';
import { openLocalCollection } from 'providers/ReduxStore/slices/collections/actions';
import { addCollectionToWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import StyledWrapper from './StyledWrapper';
const TitleBar = () => {
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [addCollectionToWSModalOpen, setAddCollectionToWSModalOpen] = useState(false);
const { activeWorkspaceUid } = useSelector((state) => state.workspaces);
const isPlatformElectron = isElectron();
@@ -48,18 +48,10 @@ const TitleBar = () => {
.catch(() => toast.error('An error occured while adding collection to workspace'));
};
const handleImportCollection = () => {
importCollection()
.then((collection) => {
dispatch(collectionImported({ collection: collection }));
dispatch(addCollectionToWorkspace(activeWorkspaceUid, collection.uid));
})
.catch((err) => console.log(err));
};
return (
<StyledWrapper className="px-2 py-2">
{createCollectionModalOpen ? <CreateCollection isLocal={createCollectionModalOpen === 'local' ? true : false} onClose={() => setCreateCollectionModalOpen(false)} /> : null}
{importCollectionModalOpen ? <ImportCollection onClose={() => setImportCollectionModalOpen(false)} /> : null}
{addCollectionToWSModalOpen ? (
<SelectCollection title="Add Collection to Workspace" onClose={() => setAddCollectionToWSModalOpen(false)} onSelect={handleAddCollectionToWorkspace} />
@@ -91,7 +83,7 @@ const TitleBar = () => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
handleImportCollection();
setImportCollectionModalOpen(true);
}}
>
Import Collection

View File

@@ -124,7 +124,7 @@ const Sidebar = () => {
title="GitHub"
></iframe>
</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.2.0</div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.3.0</div>
</div>
</div>
</div>

View File

@@ -10,13 +10,15 @@ import { IconBrandGithub, IconPlus, IconUpload, IconFiles, IconFolders, IconPlay
import Bruno from 'components/Bruno';
import CreateCollection from 'components/Sidebar/CreateCollection';
import SelectCollection from 'components/Sidebar/Collections/SelectCollection';
import importCollection, { importSampleCollection } from 'utils/collections/import';
import { importSampleCollection } from 'utils/importers/bruno-collection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import StyledWrapper from './StyledWrapper';
const Welcome = () => {
const dispatch = useDispatch();
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [addCollectionToWSModalOpen, setAddCollectionToWSModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const { activeWorkspaceUid } = useSelector((state) => state.workspaces);
const isPlatformElectron = isElectron();
@@ -29,15 +31,6 @@ const Welcome = () => {
.catch(() => toast.error('An error occured while adding collection to workspace'));
};
const handleImportCollection = () => {
importCollection()
.then((collection) => {
dispatch(collectionImported({ collection: collection }));
dispatch(addCollectionToWorkspace(activeWorkspaceUid, collection.uid));
})
.catch((err) => console.log(err));
};
const handleImportSampleCollection = () => {
importSampleCollection()
.then((collection) => {
@@ -58,6 +51,7 @@ const Welcome = () => {
return (
<StyledWrapper className="pb-4 px-6 mt-6">
{createCollectionModalOpen ? <CreateCollection isLocal={createCollectionModalOpen === 'local' ? true : false} onClose={() => setCreateCollectionModalOpen(false)} /> : null}
{importCollectionModalOpen ? <ImportCollection onClose={() => setImportCollectionModalOpen(false)} /> : null}
{addCollectionToWSModalOpen ? (
<SelectCollection title="Add Collection to Workspace" onClose={() => setAddCollectionToWSModalOpen(false)} onSelect={handleAddCollectionToWorkspace} />
@@ -73,23 +67,23 @@ const Welcome = () => {
<div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center">
<IconPlus size={18} strokeWidth={2} />
<span className="label ml-2" onClick={() => setCreateCollectionModalOpen(true)}>
<span className="label ml-2" id="create-collection" onClick={() => setCreateCollectionModalOpen(true)}>
Create Collection
</span>
</div>
<div className="flex items-center ml-6">
<IconFiles size={18} strokeWidth={2} />
<span className="label ml-2" onClick={() => setAddCollectionToWSModalOpen(true)}>
<span className="label ml-2" id="add-collection" onClick={() => setAddCollectionToWSModalOpen(true)}>
Add Collection to Workspace
</span>
</div>
<div className="flex items-center ml-6" onClick={handleImportCollection}>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconUpload size={18} strokeWidth={2} />
<span className="label ml-2">Import Collection</span>
<span className="label ml-2" id="import-collection">Import Collection</span>
</div>
<div className="flex items-center ml-6" onClick={handleImportSampleCollection}>
<IconPlayerPlay size={18} strokeWidth={2} />
<span className="label ml-2">Load Sample Collection</span>
<span className="label ml-2" id="load-sample-collection">Load Sample Collection</span>
</div>
</div>

View File

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

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import getConfig from 'next/config';
import { PostHog } from 'posthog-node';
import platformLib from 'platform';
import { uuid } from 'utils/common';
import { isElectron } from 'utils/common/platform';
const { publicRuntimeConfig } = getConfig();
const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
let posthogClient = null;
const isPlaywrightTestRunning = () => {
return publicRuntimeConfig.PLAYWRIGHT ? true : false;
};
// Todo support chrome and firefox extension
const getPlatform = () => {
return isElectron() ? 'electron' : 'web';
};
const getPosthogClient = () => {
if(posthogClient) {
return posthogClient;
}
posthogClient = new PostHog(posthogApiKey);
return posthogClient;
};
const getAnonymousTrackingId = () => {
let id = localStorage.getItem('bruno.anonymousTrackingId');
if(!id || !id.length || id.length !== 21) {
id = uuid();
localStorage.setItem('bruno.anonymousTrackingId', id);
}
return id;
};
const trackStart = () => {
if(isPlaywrightTestRunning()) {
return;
}
const trackingId = getAnonymousTrackingId();
const platform = getPlatform();
const client = getPosthogClient();
client.capture({
distinctId: trackingId,
event: 'start',
properties: {
platform: platform,
os: platformLib.os.family
}
});
};
const useTelemetry = () => {
useEffect(() => {
trackStart();
setInterval(trackStart , 24 * 60 * 60 * 1000);
}, []);
};
export default useTelemetry;

View File

@@ -21,6 +21,7 @@ const darkTheme = {
sidebar: {
color: '#ccc',
muted: '#9d9d9d',
bg: '#252526',
workspace: {
bg: '#3D3D3D'
@@ -197,30 +198,7 @@ const darkTheme = {
plainGrid: {
hoverBg: '#3D3D3D'
},
'primary-text': '#ffffff',
'secondary-text': '#929292',
'sidebar-collection-item-active-background': '#e1e1e1',
'sidebar-background': '#252526',
'sidebar-bottom-bg': '#68217a',
'request-dragbar-background': '#efefef',
'request-dragbar-background-active': 'rgb(200, 200, 200)',
'tab-inactive': 'rgb(155 155 155)',
'tab-active-border': '#546de5',
'layout-border': '#dedede',
'codemirror-border': '#efefef',
'codemirror-background': 'rgb(243, 243, 243)',
'text-link': '#1663bb',
'text-danger': 'rgb(185, 28, 28)',
'background-danger': '#dc3545',
'method-get': 'rgb(5, 150, 105)',
'method-post': '#8e44ad',
'method-delete': 'rgb(185, 28, 28)',
'method-patch': 'rgb(52 52 52)',
'method-options': 'rgb(52 52 52)',
'method-head': 'rgb(52 52 52)',
'table-stripe': '#f3f3f3'
}
};
export default darkTheme;

View File

@@ -21,6 +21,7 @@ const lightTheme = {
sidebar: {
color: 'rgb(52, 52, 52)',
muted: '#4b5563',
bg: '#F3F3F3',
workspace: {
bg: '#e1e1e1'
@@ -201,30 +202,7 @@ const lightTheme = {
plainGrid: {
hoverBg: '#f4f4f4'
},
'primary-text': 'rgb(52 52 52)',
'secondary-text': '#929292',
'sidebar-collection-item-active-background': '#e1e1e1',
'sidebar-background': '#f3f3f3',
'sidebar-bottom-bg': '#f3f3f3',
'request-dragbar-background': '#efefef',
'request-dragbar-background-active': 'rgb(200, 200, 200)',
'tab-inactive': 'rgb(155 155 155)',
'tab-active-border': '#546de5',
'layout-border': '#dedede',
'codemirror-border': '#efefef',
'codemirror-background': 'rgb(243, 243, 243)',
'text-link': '#1663bb',
'text-danger': 'rgb(185, 28, 28)',
'background-danger': '#dc3545',
'method-get': 'rgb(5, 150, 105)',
'method-post': '#8e44ad',
'method-delete': 'rgb(185, 28, 28)',
'method-patch': 'rgb(52 52 52)',
'method-options': 'rgb(52 52 52)',
'method-head': 'rgb(52 52 52)',
'table-stripe': '#f3f3f3'
}
};
export default lightTheme;

View File

@@ -1,99 +0,0 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import toast from 'react-hot-toast';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import { collectionSchema } from '@usebruno/schema';
import { saveCollectionToIdb } from 'utils/idb';
import sampleCollection from './samples/sample-collection.json';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseJsonCollection = (str) => {
return new Promise((resolve, reject) => {
try {
let parsed = JSON.parse(str);
return resolve(parsed);
} catch (err) {
toast.error('Unable to parse the collection json file');
reject(err);
}
});
};
const validateSchema = (collection = {}) => {
return new Promise((resolve, reject) => {
collectionSchema
.validate(collection)
.then(() => resolve(collection))
.catch((err) => {
toast.error('The Collection file is corrupted');
reject(err);
});
});
};
const updateUidsInCollection = (_collection) => {
const collection = cloneDeep(_collection);
collection.uid = uuid();
const updateItemUids = (items = []) => {
each(items, (item) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);
}
});
};
updateItemUids(collection.items);
return collection;
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseJsonCollection)
.then(validateSchema)
.then(updateUidsInCollection)
.then(validateSchema)
.then((collection) => saveCollectionToIdb(window.__idb, collection))
.then((collection) => {
toast.success('Collection imported successfully');
resolve(collection);
})
.catch((err) => {
toast.error('Import collection failed');
reject(err);
});
});
};
export const importSampleCollection = () => {
return new Promise((resolve, reject) => {
validateSchema(sampleCollection)
.then(updateUidsInCollection)
.then(validateSchema)
.then((collection) => saveCollectionToIdb(window.__idb, collection))
.then(resolve)
.catch(reject);
});
};
export default importCollection;

View File

@@ -1,4 +1,4 @@
import template from 'lodash/template';
import reckon from 'reckonjs';
import get from 'lodash/get';
import each from 'lodash/each';
import find from 'lodash/find';
@@ -356,21 +356,26 @@ export const isLocalCollection = (collection) => {
};
export const interpolateEnvironmentVars = (item, variables) => {
const envVars = {};
const { request } = item;
const envVars = {
interpolation: { escapeValue: true }
};
const _item = item.draft ? item.draft : item;
const { request } = _item;
each(variables, (variable) => {
envVars[variable.name] = variable.value;
});
const templateOpts = {
interpolate: /{{([\s\S]+?)}}/g, //interpolate content using markers `{{}}`
evaluate: null, // prevent any js evaluation
escape: null // disable any escaping
// TODO: Find a better interpolation library
const interpolate = (str) => {
if(!str || !str.length || typeof str !== "string") {
return str;
}
return str.reckon(envVars);
};
const interpolate = (str) => template(str, templateOpts)(envVars);
request.url = interpolate(request.url);
console.log(request.url);
each(request.headers, (header) => {
header.value = interpolate(header.value);

View File

@@ -0,0 +1,56 @@
import fileDialog from 'file-dialog';
import { saveCollectionToIdb } from 'utils/idb';
import { BrunoError } from 'utils/common/error';
import { validateSchema, updateUidsInCollection } from './common';
import sampleCollection from './samples/sample-collection.json';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseJsonCollection = (str) => {
return new Promise((resolve, reject) => {
try {
let parsed = JSON.parse(str);
return resolve(parsed);
} catch (err) {
console.log(err);
reject(new BrunoError('Unable to parse the collection json file'));
}
});
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseJsonCollection)
.then(validateSchema)
.then(updateUidsInCollection)
.then(validateSchema)
.then((collection) => saveCollectionToIdb(window.__idb, collection))
.then((collection) => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export const importSampleCollection = () => {
return new Promise((resolve, reject) => {
validateSchema(sampleCollection)
.then(updateUidsInCollection)
.then(validateSchema)
.then((collection) => saveCollectionToIdb(window.__idb, collection))
.then(resolve)
.catch(reject);
});
};
export default importCollection;

View File

@@ -0,0 +1,44 @@
import each from 'lodash/each';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { uuid } from 'utils/common';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';
export const validateSchema = (collection = {}) => {
return new Promise((resolve, reject) => {
collectionSchema
.validate(collection)
.then(() => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('The Collection file is corrupted'));
});
});
};
export const updateUidsInCollection = (_collection) => {
const collection = cloneDeep(_collection);
collection.uid = uuid();
const updateItemUids = (items = []) => {
each(items, (item) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);
}
});
};
updateItemUids(collection.items);
return collection;
};

View File

@@ -0,0 +1,187 @@
import each from 'lodash/each';
import get from 'lodash/get';
import fileDialog from 'file-dialog';
import { uuid } from 'utils/common';
import { saveCollectionToIdb } from 'utils/idb';
import { BrunoError } from 'utils/common/error';
import { validateSchema, updateUidsInCollection } from './common';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const isItemAFolder = (item) => {
return !item.request;
};
const importPostmanV2CollectionItem = (brunoParent, item) => {
brunoParent.items = brunoParent.items || [];
each(item, (i) => {
if(isItemAFolder(i)) {
const brunoFolderItem = {
uid: uuid(),
name: i.name,
type: 'folder',
items: []
};
brunoParent.items.push(brunoFolderItem);
if(i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item);
}
} else {
if(i.request) {
const brunoRequestItem = {
uid: uuid(),
name: i.name,
type: 'http-request',
request: {
url: get(i, 'request.url.raw'),
method: i.request.method,
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
}
};
const bodyMode = get(i, 'request.body.mode');
if(bodyMode) {
if(bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if(bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if(bodyMode === 'raw') {
const language = get(i, 'request.body.options.raw.language');
if(language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
each(i.request.header, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
each(get(i, 'request.url.query'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
brunoParent.items.push(brunoRequestItem);
}
}
});
};
const importPostmanV2Collection = (collection) => {
const brunoCollection = {
name: collection.info.name,
uid: uuid(),
version: "1",
items: [],
environments: []
};
importPostmanV2CollectionItem(brunoCollection, collection.item);
return brunoCollection;
};
const parsePostmanCollection = (str) => {
return new Promise((resolve, reject) => {
try {
let collection = JSON.parse(str);
let schema = get(collection, 'info.schema');
let v2Schemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
];
if(v2Schemas.includes(schema)) {
return resolve(importPostmanV2Collection(collection));
}
throw new BrunoError('Unknown postman schema');
} catch (err) {
console.log(err);
if(err instanceof BrunoError) {
return reject(err);
}
return reject(new BrunoError('Unable to parse the postman collection json file'));
}
});
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parsePostmanCollection)
.then(validateSchema)
.then(updateUidsInCollection)
.then(validateSchema)
.then((collection) => saveCollectionToIdb(window.__idb, collection))
.then((collection) => resolve(collection))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export default importCollection;

View File

@@ -1,5 +1,5 @@
{
"version": "0.2.0",
"version": "0.3.0",
"name": "bruno",
"description": "Opensource API Client",
"homepage": "https://www.usebruno.com",

110
playwright.config.js Normal file
View File

@@ -0,0 +1,110 @@
// @ts-check
const { devices } = require('@playwright/test');
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
process.env.PLAYWRIGHT = "1";
/**
* @see https://playwright.dev/docs/test-configuration
* @type {import('@playwright/test').PlaywrightTestConfig}
*/
const config = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev:web',
port: 3000,
},
};
module.exports = config;

View File

@@ -1,5 +1,5 @@
# bruno
Opensource API Client.
Local-first, Opensource API Client.
### Live Demo 🏂
Woof! Lets play with some api's [here](https://play.usebruno.com).
@@ -7,7 +7,12 @@ Your api must allow CORS for it to work in the browser, else checkout the chrome
You can visit the [Website](https://www.usebruno.com) or watch a [4 min demo](https://www.youtube.com/watch?v=wwXJW7_qyLA)
![bruno](packages/bruno-app/public/images/landing.png)
![bruno](assets/images/landing.png)
### Bring Your Own Version Control ✨
Bruno is built from the ground up with the Local-first paradigm in mind. This allows developers to directly store there collections on top of their local filesystem. The collections are mirrored as folders and files on the filesystem.
![bruno](assets/images/local-collections.png)
### Comparision with Similar tools ⚖️
Bruno is at early stages of development, and does not yet have all the bells and whistles.
@@ -24,18 +29,27 @@ Here is a rundown of key areas where bruno is different from similar tools out
| Run in Browser | ✔️ | ✔️ | ✖️ | ✔️ |
| Multi Tab Requests | ✔️ | ✔️ | ✖️ | ✖️ |
### Contribute 👩‍💻🧑‍💻
I am happy that you are looking to improve bruno. Please checkout the [contributing guide](contributing.md)
Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case.
### Support ❤️
Woof! If you like project, hit that ⭐ button !!
### Authors
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### Stay in touch 🌐
[Twitter](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com)
### License 📄
[MIT](license.md)

41
tests/home.spec.js Normal file
View File

@@ -0,0 +1,41 @@
const { test, expect } = require('@playwright/test');
const { faker } = require('@faker-js/faker');
const { HomePage } = require('../tests/pages/home.page');
test.describe('bruno e2e test', () => {
let homePage;
test.beforeEach(async ({ page }) => {
homePage = new HomePage(page);
await homePage.open();
await expect(page).toHaveURL('/');
await expect(page).toHaveTitle(/bruno/);
});
test('user should be able to load & use sample collection', async () => {
await homePage.loadSampleCollection();
await expect(homePage.loadSampleCollectionToastSuccess).toBeVisible();
await homePage.getUsers();
await expect(homePage.statusRequestSuccess).toBeVisible();
await homePage.getSingleUser();
await expect(homePage.statusRequestSuccess).toBeVisible();
await homePage.getUserNotFound();
await expect(homePage.statusRequestNotFound).toBeVisible();
await homePage.createUser();
await expect(homePage.statusRequestCreated).toBeVisible();
await homePage.updateUser();
await expect(homePage.statusRequestSuccess).toBeVisible();
});
test('user should be able to create new collection', async () => {
await homePage.createCollection(faker.random.words());
await expect(homePage.createCollectionToastSuccess).toBeVisible();
})
});

71
tests/pages/home.page.js Normal file
View File

@@ -0,0 +1,71 @@
exports.HomePage = class HomePage {
constructor(page) {
this.page = page;
// welcome
this.createCollectionSelector = page.locator('#create-collection');
this.addCollectionSelector = page.locator('#add-collection');
this.importCollectionSelector = page.locator('#import-collection');
this.loadSampleCollectionSelector = page.locator('#load-sample-collection');
// sample collection
this.loadSampleCollectionToastSuccess = page.getByText('Sample Collection loaded successfully');
this.sampeCollectionSelector = page.locator('#sidebar-collection-name');
this.getUsersSelector = page.getByText('Users');
this.getSingleUserSelector = page.getByText('Single User');
this.getUserNotFoundSelector = page.getByText('User Not Found');
this.postCreateSelector = page.getByText('Create');
this.putUpdateSelector = page.getByText('Update');
// request panel
this.sendRequestButton = page.locator('#send-request');
this.statusRequestSuccess = page.getByText('200 OK');
this.statusRequestNotFound = page.getByText('404 Not Found');
this.statusRequestCreated = page.getByText('201 Created');
// create collection
this.collectionNameField = page.locator('#collection-name');
this.submitButton = page.locator(`button[type='submit']`);
this.createCollectionToastSuccess = page.getByText('Collection created');
}
async open() {
await this.page.goto('/');
}
async loadSampleCollection() {
await this.loadSampleCollectionSelector.click();
}
async getUsers() {
await this.sampeCollectionSelector.click();
await this.getUsersSelector.click();
await this.sendRequestButton.click();
}
async getSingleUser() {
await this.getSingleUserSelector.click();
await this.sendRequestButton.click();
}
async getUserNotFound() {
await this.getUserNotFoundSelector.click();
await this.sendRequestButton.click();
}
async createUser() {
await this.postCreateSelector.click();
await this.sendRequestButton.click();
}
async updateUser() {
await this.putUpdateSelector.click();
await this.sendRequestButton.click();
}
async createCollection(collectionName) {
await this.createCollectionSelector.click();
await this.collectionNameField.fill(collectionName);
await this.submitButton.click();
}
}