feat: collection runner

This commit is contained in:
Anoop M D 2023-02-01 17:06:04 +05:30
parent c5b509115a
commit 58bc247c53
21 changed files with 820 additions and 87 deletions

View File

@ -12,6 +12,7 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound'; import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl'; import QueryUrl from 'components/RequestPane/QueryUrl';
import NetworkError from 'components/ResponsePane/NetworkError'; import NetworkError from 'components/ResponsePane/NetworkError';
import RunnerResults from 'components/RunnerResults';
import { DocExplorer } from '@usebruno/graphql-docs'; import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -112,6 +113,11 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">Collection not found!</div>; return <div className="pb-4 px-4">Collection not found!</div>;
} }
const showRunner = collection.showRunner;
if(showRunner) {
return <RunnerResults collection={collection}/>;
}
const item = findItemInCollection(collection, activeTabUid); const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) { if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />; return <RequestNotFound itemUid={activeTabUid} />;

View File

@ -1,10 +1,20 @@
import React from 'react'; import React from 'react';
import { IconFiles } from '@tabler/icons'; import { IconFiles, IconRun } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import VariablesView from 'components/VariablesView'; import VariablesView from 'components/VariablesView';
import { useDispatch } from 'react-redux';
import { toggleRunnerView } from 'providers/ReduxStore/slices/collections';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const CollectionToolBar = ({ collection }) => { const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
const handleRun = () => {
dispatch(toggleRunnerView({
collectionUid: collection.uid
}));
};
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="flex items-center p-2"> <div className="flex items-center p-2">
@ -13,6 +23,9 @@ const CollectionToolBar = ({ collection }) => {
<span className="ml-2 mr-4 font-semibold">{collection.name}</span> <span className="ml-2 mr-4 font-semibold">{collection.name}</span>
</div> </div>
<div className="flex flex-1 items-center justify-end"> <div className="flex flex-1 items-center justify-end">
<span className="mr-2">
<IconRun className="cursor-pointer" size={20} strokeWidth={1.5} onClick={handleRun} />
</span>
<VariablesView collection={collection}/> <VariablesView collection={collection}/>
<EnvironmentSelector collection={collection} /> <EnvironmentSelector collection={collection} />
</div> </div>

View File

@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react';
import find from 'lodash/find'; import find from 'lodash/find';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import classnames from 'classnames'; import classnames from 'classnames';
import { IconHome2, IconChevronRight, IconChevronLeft } from '@tabler/icons'; import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { focusTab } from 'providers/ReduxStore/slices/tabs'; import { focusTab } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest'; import NewRequest from 'components/Sidebar/NewRequest';
@ -76,6 +76,8 @@ const RequestTabs = () => {
}); });
}; };
const showRunner = activeCollection && activeCollection.showRunner;
// Todo: Must support ephermal requests // Todo: Must support ephermal requests
return ( return (
<StyledWrapper className={getRootClassname()}> <StyledWrapper className={getRootClassname()}>
@ -83,6 +85,7 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length ? ( {collectionRequestTabs && collectionRequestTabs.length ? (
<> <>
<CollectionToolBar collection={activeCollection} /> <CollectionToolBar collection={activeCollection} />
{!showRunner ? (
<div className="flex items-center pl-4"> <div className="flex items-center pl-4">
<ul role="tablist"> <ul role="tablist">
{showChevrons ? ( {showChevrons ? (
@ -136,6 +139,7 @@ const RequestTabs = () => {
</li> */} </li> */}
</ul> </ul>
</div> </div>
) : null}
</> </>
) : null} ) : null}
</StyledWrapper> </StyledWrapper>

View File

@ -6,13 +6,18 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const QueryResult = ({ item, collection, value, width }) => { const QueryResult = ({ item, collection, value, width, disableRunEventListener }) => {
const { const {
storedTheme storedTheme
} = useTheme(); } = useTheme();
const dispatch = useDispatch(); const dispatch = useDispatch();
const onRun = () => dispatch(sendRequest(item, collection.uid)); const onRun = () => {
if(disableRunEventListener) {
return;
}
dispatch(sendRequest(item, collection.uid));
};
return ( return (
<StyledWrapper className="px-3 w-full" style={{ maxWidth: width }}> <StyledWrapper className="px-3 w-full" style={{ maxWidth: width }}>

View File

@ -0,0 +1,27 @@
import React from 'react';
const TestResultsLabel = ({ results }) => {
if(!results || !results.length) {
return 'Tests';
}
const numberOfTests = results.length;
const numberOfFailedTests = results.filter(result => result.status === 'fail').length;
return (
<div className='flex items-center'>
<div>Tests</div>
{numberOfFailedTests ? (
<sup className='sups some-tests-failed ml-1 font-medium'>
{numberOfFailedTests}
</sup>
) : (
<sup className='sups all-tests-passed ml-1 font-medium'>
{numberOfTests}
</sup>
)}
</div>
);
};
export default TestResultsLabel;

View File

@ -3,12 +3,13 @@ import forOwn from 'lodash/forOwn';
import { safeStringifyJSON } from 'utils/common'; import { safeStringifyJSON } from 'utils/common';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const Timeline = ({ item }) => { const Timeline = ({ request, response}) => {
const request = item.requestSent || {};
const response = item.response || {};
const requestHeaders = []; const requestHeaders = [];
const responseHeaders = response.headers || []; const responseHeaders = response.headers || [];
request = request || {};
response = response || {};
forOwn(request.headers, (value, key) => { forOwn(request.headers, (value, key) => {
requestHeaders.push({ requestHeaders.push({
name: key, name: key,

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import find from 'lodash/find'; import find from 'lodash/find';
import classnames from 'classnames'; import classnames from 'classnames';
import { safeStringifyJSON } from 'utils/common';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryResult from './QueryResult'; import QueryResult from './QueryResult';
@ -12,32 +13,9 @@ import ResponseTime from './ResponseTime';
import ResponseSize from './ResponseSize'; import ResponseSize from './ResponseSize';
import Timeline from './Timeline'; import Timeline from './Timeline';
import TestResults from './TestResults'; import TestResults from './TestResults';
import TestResultsLabel from './TestResultsLabel';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const TestResultsLabel = ({ results }) => {
if(!results || !results.length) {
return 'Tests';
}
const numberOfTests = results.length;
const numberOfFailedTests = results.filter(result => result.status === 'fail').length;
return (
<div className='flex items-center'>
<div>Tests</div>
{numberOfFailedTests ? (
<sup className='sups some-tests-failed ml-1 font-medium'>
{numberOfFailedTests}
</sup>
) : (
<sup className='sups all-tests-passed ml-1 font-medium'>
{numberOfTests}
</sup>
)}
</div>
);
};
const ResponsePane = ({ rightPaneWidth, item, collection }) => { const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs); const tabs = useSelector((state) => state.tabs.tabs);
@ -62,14 +40,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
item={item} item={item}
collection={collection} collection={collection}
width={rightPaneWidth} width={rightPaneWidth}
value={response.data ? JSON.stringify(response.data, null, 2) : '' value={response.data ? safeStringifyJSON(response.data, true) : ''}
} />; />;
} }
case 'headers': { case 'headers': {
return <ResponseHeaders headers={response.headers} />; return <ResponseHeaders headers={response.headers} />;
} }
case 'timeline': { case 'timeline': {
return <Timeline item={item} />; return <Timeline request={item.requestSent} response={item.response}/>;
} }
case 'tests': { case 'tests': {
return <TestResults results={item.testResults} />; return <TestResults results={item.testResults} />;

View File

@ -0,0 +1,38 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.some-tests-failed {
color: ${(props) => props.theme.colors.text.danger} !important;
}
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import classnames from 'classnames';
import { safeStringifyJSON } from 'utils/common';
import QueryResult from 'components/ResponsePane/QueryResult';
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseTime from 'components/ResponsePane/ResponseTime';
import ResponseSize from 'components/ResponsePane/ResponseSize';
import Timeline from 'components/ResponsePane/Timeline';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
const {
requestSent,
responseReceived,
testResults
} = item;
const headers = get(item, 'responseReceived.headers', {});
const status = get(item, 'responseReceived.status', 0);
const size = get(item, 'responseReceived.size', 0);
const duration = get(item, 'responseReceived.duration', 0);
const selectTab = (tab) => setSelectedTab(tab);
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
return <QueryResult
item={item}
collection={collection}
width={rightPaneWidth}
disableRunEventListener={true}
value={(responseReceived && responseReceived.data) ? safeStringifyJSON(responseReceived.data, true) : ''}
/>;
}
case 'headers': {
return <ResponseHeaders headers={headers} />;
}
case 'timeline': {
return <Timeline request={requestSent} response={responseReceived} />;
}
case 'tests': {
return <TestResults results={testResults} />;
}
default: {
return <div>404 | Not found</div>;
}
}
};
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === selectedTab
});
};
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center px-3 tabs" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
</div>
<div className={getTabClassname('timeline')} role="tab" onClick={() => selectTab('timeline')}>
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={testResults} />
</div>
<div className="flex flex-grow justify-end items-center">
<StatusCode status={status} />
<ResponseTime duration={duration} />
<ResponseSize size={size} />
</div>
</div>
<section className="flex flex-grow mt-5">{getTabPanel(selectedTab)}</section>
</StyledWrapper>
);
};
export default ResponsePane;

View File

@ -0,0 +1,24 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.item-path {
.link {
color: ${(props) => props.theme.textLink};
}
}
/* test results */
.test-success {
color: ${(props) => props.theme.colors.text.green};
}
.test-failure {
color: ${(props) => props.theme.colors.text.danger};
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
}
`;
export default Wrapper;

View File

@ -0,0 +1,127 @@
import React, { useState, useEffect } from 'react';
import path from 'path';
import { get, each, cloneDeep } from 'lodash';
import { findItemInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
}
export default function RunnerResults({collection}) {
const [selectedItem, setSelectedItem] = useState(null);
useEffect(() => {
if(!collection.runnerResult) {
setSelectedItem(null);
}
}, [collection, setSelectedItem]);
const collectionCopy = cloneDeep(collection);
const items = cloneDeep(get(collection, 'runnerResult.items', []));
each(items, (item) => {
const info = findItemInCollection(collectionCopy, item.uid);
item.name = info.name;
item.type = info.type;
item.filename = info.filename;
item.pathname = info.pathname;
item.relativePath = getRelativePath(collection.pathname, info.pathname);
if(item.testResults) {
const failed = item.testResults.filter((result) => result.status === 'fail');
item.testStatus = failed.length ? 'fail' : 'pass';
} else {
item.testStatus = 'pass';
}
});
return (
<StyledWrapper className='px-4'>
<div className='font-medium mt-6 mb-4 title flex items-center'>
Runner
<IconRun size={20} strokeWidth={1.5} className='ml-2'/>
</div>
<div className='flex'>
<div className='flex flex-col flex-1'>
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{item.testStatus === 'pass' ? (
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5}/>
) : (
<IconCircleX className="test-failure" size={20} strokeWidth={1.5}/>
)}
</span>
<span className='mr-1 ml-2'>{item.relativePath}</span>
{item.status !== "completed" ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5}/>
) : (
<span className='text-xs link cursor-pointer' onClick={() => setSelectedItem(item)}>
(<span className='mr-1'>
{get(item.responseReceived, 'status')}
</span>
<span>
{get(item.responseReceived, 'statusText')}
</span>)
</span>
)}
</div>
<ul className="pl-8">
{item.testResults ? item.testResults.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2"/>
{result.description}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2"/>
{result.description}
</span>
<span className="error-message pl-8 text-xs">
{result.error}
</span>
</>
)}
</li>
)): null}
</ul>
</div>
</div>
);
})}
</div>
<div className='flex flex-1' style={{width: '50%'}}>
{selectedItem ? (
<div className='flex flex-col w-full overflow-auto'>
<div className="flex items-center px-3 mb-4 font-medium">
<span className='mr-2'>{selectedItem.relativePath}</span>
<span>
{selectedItem.testStatus === 'pass' ? (
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5}/>
) : (
<IconCircleX className="test-failure" size={20} strokeWidth={1.5}/>
)}
</span>
</div>
{/* <div className='px-3 mb-4 font-medium'>{selectedItem.relativePath}</div> */}
<ResponsePane item={selectedItem} collection={collection}/>
</div>
) : null}
</div>
</div>
</StyledWrapper>
);
};

View File

@ -0,0 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.bruno-modal-content {
padding-bottom: 1rem;
}
`;
export default Wrapper;

View File

@ -0,0 +1,63 @@
import React from 'react';
import get from 'lodash/get';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const RunCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch();
const onSubmit = (recursive) => {
dispatch(runCollectionFolder(collection.uid, item.uid, recursive));
onClose();
};
const runLength = get(item, 'items.length', 0);
const items = flattenItems(item.items);
const requestItems = items.filter((item) => item.type !== 'folder');
const recursiveRunLength = requestItems.length;
return (
<StyledWrapper>
<Modal size="md" title='Collection Runner' hideFooter={true} handleCancel={onClose}>
<div className='mb-1'>
<span className='font-medium'>Run</span>
<span className='ml-1 text-xs'>({runLength.length} requests)</span>
</div>
<div className='mb-8'>
This will only run the requests in this folder.
</div>
<div className='mb-1'>
<span className='font-medium'>Recursive Run</span>
<span className='ml-1 text-xs'>({recursiveRunLength.length} requests)</span>
</div>
<div className='mb-8'>
This will run all the requests in this folder and all its subfolders.
</div>
<div className="flex justify-end bruno-modal-footer">
<span className='mr-3'>
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
</div>
</Modal>
</StyledWrapper>
);
};
export default RunCollectionItem;

View File

@ -15,6 +15,7 @@ import RequestMethod from './RequestMethod';
import RenameCollectionItem from './RenameCollectionItem'; import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem'; import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs'; import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections'; import { getDefaultRequestPaneTab } from 'utils/collections';
@ -33,6 +34,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed); const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
const [{ isDragging }, drag] = useDrag({ const [{ isDragging }, drag] = useDrag({
@ -151,6 +153,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
{deleteItemModalOpen && <DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />} {deleteItemModalOpen && <DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />}
{newRequestModalOpen && <NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />} {newRequestModalOpen && <NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />}
{newFolderModalOpen && <NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />} {newFolderModalOpen && <NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />}
{runCollectionModalOpen && <RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}> <div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className="flex items-center h-full w-full"> <div className="flex items-center h-full w-full">
{indents && indents.length {indents && indents.length
@ -211,6 +214,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
> >
New Folder New Folder
</div> </div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setRunCollectionModalOpen(true);
}}
>
Run
</div>
</> </>
)} )}
<div <div

View File

@ -11,7 +11,8 @@ import {
requestQueuedEvent, requestQueuedEvent,
testResultsEvent, testResultsEvent,
scriptEnvironmentUpdateEvent, scriptEnvironmentUpdateEvent,
collectionRenamedEvent collectionRenamedEvent,
runFolderEvent
} from 'providers/ReduxStore/slices/collections'; } from 'providers/ReduxStore/slices/collections';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { openCollectionEvent, collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections/actions'; import { openCollectionEvent, collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections/actions';
@ -105,6 +106,10 @@ const useCollectionTreeSync = () => {
dispatch(collectionRenamedEvent(val)); dispatch(collectionRenamedEvent(val));
}; };
const _runFolderEvent = (val) => {
dispatch(runFolderEvent(val));
};
ipcRenderer.invoke('renderer:ready'); ipcRenderer.invoke('renderer:ready');
const removeListener1 = ipcRenderer.on('main:collection-opened', _openCollection); const removeListener1 = ipcRenderer.on('main:collection-opened', _openCollection);
@ -116,6 +121,7 @@ const useCollectionTreeSync = () => {
const removeListener7 = ipcRenderer.on('main:http-request-queued', _httpRequestQueued); const removeListener7 = ipcRenderer.on('main:http-request-queued', _httpRequestQueued);
const removeListener8 = ipcRenderer.on('main:test-results', _testResults); const removeListener8 = ipcRenderer.on('main:test-results', _testResults);
const removeListener9 = ipcRenderer.on('main:collection-renamed', _collectionRenamed); const removeListener9 = ipcRenderer.on('main:collection-renamed', _collectionRenamed);
const removeListener10 = ipcRenderer.on('main:run-folder-event', _runFolderEvent);
return () => { return () => {
removeListener1(); removeListener1();
@ -127,6 +133,7 @@ const useCollectionTreeSync = () => {
removeListener7(); removeListener7();
removeListener8(); removeListener8();
removeListener9(); removeListener9();
removeListener10();
}; };
}, [isElectron]); }, [isElectron]);
}; };

View File

@ -24,6 +24,7 @@ import { saveCollectionToIdb } from 'utils/idb';
import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import { import {
resetRunResults,
requestCancelled, requestCancelled,
responseReceived, responseReceived,
newItem as _newItem, newItem as _newItem,
@ -138,6 +139,35 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) =>
.catch((err) => console.log(err)); .catch((err) => console.log(err));
}; };
export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const folder = findItemInCollection(collectionCopy, folderUid);
if (!folder) {
return reject(new Error('Folder not found'));
}
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
dispatch(resetRunResults({
collectionUid: collection.uid
}));
ipcRenderer
.invoke('renderer:run-collection-folder', folder, collectionCopy, environment, recursive)
.then(resolve)
.catch(reject);
});
};
export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getState) => { export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -682,7 +712,8 @@ export const openCollectionEvent = (uid, pathname, name) => (dispatch, getState)
uid: uid, uid: uid,
name: name, name: name,
pathname: pathname, pathname: pathname,
items: [] items: [],
showRunner: false
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -849,6 +849,59 @@ export const collectionsSlice = createSlice({
if (collection) { if (collection) {
collection.name = newName; collection.name = newName;
} }
},
toggleRunnerView: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
console.log('here');
if (collection) {
console.log('here2');
collection.showRunner = !collection.showRunner;
}
},
resetRunResults: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.runnerResult = null;
}
},
runFolderEvent: (state, action) => {
const { collectionUid, folderUid, itemUid, type } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const folder = findItemInCollection(collection, folderUid);
const request = findItemInCollection(collection, itemUid);
collection.runnerResult = collection.runnerResult || {items: []};
if(type === 'request-queued') {
collection.runnerResult.items.push({
uid: request.uid,
status: 'queued'
});
}
if(type === 'request-sent') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.status = 'running';
item.requestSent = action.payload.requestSent;
}
if(type === 'response-received') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.status = 'completed';
item.responseReceived = action.payload.responseReceived;
}
if(type === 'test-results') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.testResults = action.payload.testResults;
}
}
} }
} }
}); });
@ -901,7 +954,10 @@ export const {
collectionUnlinkDirectoryEvent, collectionUnlinkDirectoryEvent,
collectionAddEnvFileEvent, collectionAddEnvFileEvent,
testResultsEvent, testResultsEvent,
collectionRenamedEvent collectionRenamedEvent,
toggleRunnerView,
resetRunResults,
runFolderEvent
} = collectionsSlice.actions; } = collectionsSlice.actions;
export default collectionsSlice.reducer; export default collectionsSlice.reducer;

View File

@ -36,11 +36,14 @@ export const safeParseJSON = (str) => {
} }
}; };
export const safeStringifyJSON = (obj) => { export const safeStringifyJSON = (obj, indent=false) => {
if(!obj) { if(!obj) {
return obj; return obj;
} }
try { try {
if(indent) {
return JSON.stringify(obj, null, 2);
}
return JSON.stringify(obj); return JSON.stringify(obj);
} catch (e) { } catch (e) {
return obj; return obj;

View File

@ -0,0 +1,80 @@
const {
each,
filter
} = require('lodash');
const sortCollection = (collection) => {
const items = collection.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
collection.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortCollection(item);
});
};
const sortFolder = (folder = {}) => {
const items = folder.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
folder.items = folderItems.concat(requestItems);
each(folderItems, (item) => {
sortFolder(item);
});
return folder;
};
const findItemInCollection = (collection, itemId) => {
let item = null;
if (collection.uid === itemId) {
return collection;
}
if (collection.items && collection.items.length) {
collection.items.forEach((item) => {
if (item.uid === itemId) {
item = item;
} else if (item.type === 'folder') {
item = findItemInCollection(item, itemId);
}
});
}
return item;
};
const getAllRequestsInFolderRecursively = (folder = {}) => {
let requests = [];
if (folder.items && folder.items.length) {
folder.items.forEach((item) => {
if (item.type !== 'folder') {
requests.push(item);
} else {
requests = requests.concat(getAllRequestsInFolderRecursively(item));
}
});
}
return requests;
};
module.exports = {
sortCollection,
sortFolder,
findItemInCollection,
getAllRequestsInFolderRecursively
};

View File

@ -9,6 +9,10 @@ const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-requ
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
const { uuid } = require('../../utils/common'); const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars'); const interpolateVars = require('./interpolate-vars');
const {
sortFolder,
getAllRequestsInFolderRecursively
} = require('./helper');
// override the default escape function to prevent escaping // override the default escape function to prevent escaping
Mustache.escape = function (value) { Mustache.escape = function (value) {
@ -47,6 +51,22 @@ const getEnvVars = (environment = {}) => {
return envVars; return envVars;
}; };
const getSize = (data) => {
if(!data) {
return 0;
}
if(typeof data === 'string') {
return Buffer.byteLength(data, 'utf8');
}
if(typeof data === 'object') {
return Buffer.byteLength(JSON.stringify(data), 'utf8');
}
return 0;
};
const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => { const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
// handler for sending http request // handler for sending http request
ipcMain.handle('send-http-request', async (event, item, collectionUid, collectionPath, environment) => { ipcMain.handle('send-http-request', async (event, item, collectionUid, collectionPath, environment) => {
@ -197,6 +217,141 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
return Promise.reject(error); return Promise.reject(error);
} }
}); });
ipcMain.handle('renderer:run-collection-folder', async (event, folder, collection, environment, recursive) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder.uid;
try {
const envVars = getEnvVars(environment);
let folderRequests = [];
if(recursive) {
let sortedFolder = sortFolder(folder);
folderRequests = getAllRequestsInFolderRecursively(sortedFolder);
console.log('-----sortedFolder------');
console.log(sortedFolder);
console.log('-----folderRequests------');
console.log(folderRequests);
} else {
each(folder.items, (item) => {
if(item.request) {
folderRequests.push(item);
}
});
// sort requests by seq property
folderRequests.sort((a, b) => {
return a.seq - b.seq;
});
}
for(let item of folderRequests) {
const itemUid = item.uid;
const eventData = {
collectionUid,
folderUid,
itemUid
};
try {
mainWindow.webContents.send('main:run-folder-event', {
type: 'request-queued',
...eventData
});
const _request = item.draft ? item.draft.request : item.request;
const request = prepareRequest(_request);
// 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;
}
interpolateVars(request, envVars);
// todo:
// i have no clue why electron can't send the request object
// without safeParseJSON(safeStringifyJSON(request.data))
mainWindow.webContents.send('main:run-folder-event', {
type: 'request-sent',
requestSent: {
url: request.url,
method: request.method,
headers: request.headers,
data: safeParseJSON(safeStringifyJSON(request.data))
},
...eventData
});
const timeStart = Date.now();
const response = await axios(request);
const timeEnd = Date.now();
if(request.script && request.script.length) {
let script = request.script + '\n if (typeof onResponse === "function") {onResponse(__brunoResponse);}';
const scriptRuntime = new ScriptRuntime();
const result = scriptRuntime.runResponseScript(script, response, envVars, collectionPath);
mainWindow.webContents.send('main:script-environment-update', {
environment: result.environment,
collectionUid
});
}
const testFile = get(item, 'request.tests');
if(testFile && testFile.length) {
const testRuntime = new TestRuntime();
const result = testRuntime.runTests(testFile, request, response, envVars, collectionPath);
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: result.results,
...eventData
});
}
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) {
console.log(error);
mainWindow.webContents.send('main:run-folder-event', {
type: 'error',
error,
...eventData
});
}
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'error',
data: {
error : {
message: error.message
},
collectionUid,
folderUid
}
});
}
});
}; };
module.exports = registerNetworkIpc; module.exports = registerNetworkIpc;

View File

@ -85,7 +85,11 @@ const collectionSchema = Yup.object({
.matches(/^[a-zA-Z0-9]*$/, 'uid must be alphanumeric') .matches(/^[a-zA-Z0-9]*$/, 'uid must be alphanumeric')
.nullable(), .nullable(),
environments: environmentsSchema, environments: environmentsSchema,
pathname: Yup.string().nullable() pathname: Yup.string().nullable(),
showRunner: Yup.boolean(),
runnerResult: Yup.object({
items: Yup.array()
})
}).noUnknown(true).strict(); }).noUnknown(true).strict();