From 58bc247c53cf5605e1d517f32e14f5610a3129f6 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Wed, 1 Feb 2023 17:06:04 +0530 Subject: [PATCH] feat: collection runner --- .../src/components/RequestTabPanel/index.js | 6 + .../RequestTabs/CollectionToolBar/index.js | 15 +- .../src/components/RequestTabs/index.js | 102 ++++++------ .../ResponsePane/QueryResult/index.js | 9 +- .../ResponsePane/TestResultsLabel/index.js | 27 +++ .../components/ResponsePane/Timeline/index.js | 7 +- .../src/components/ResponsePane/index.js | 32 +--- .../ResponsePane/StyledWrapper.js | 38 +++++ .../RunnerResults/ResponsePane/index.js | 90 ++++++++++ .../components/RunnerResults/StyledWrapper.js | 24 +++ .../src/components/RunnerResults/index.js | 127 ++++++++++++++ .../RunCollectionItem/StyledWrapper.js | 9 + .../CollectionItem/RunCollectionItem/index.js | 63 +++++++ .../Collection/CollectionItem/index.js | 12 ++ .../providers/App/useCollectionTreeSync.js | 9 +- .../ReduxStore/slices/collections/actions.js | 33 +++- .../ReduxStore/slices/collections/index.js | 58 ++++++- packages/bruno-app/src/utils/common/index.js | 5 +- .../bruno-electron/src/ipc/network/helper.js | 80 +++++++++ .../bruno-electron/src/ipc/network/index.js | 155 ++++++++++++++++++ .../bruno-schema/src/collections/index.js | 6 +- 21 files changed, 820 insertions(+), 87 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js create mode 100644 packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js create mode 100644 packages/bruno-app/src/components/RunnerResults/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RunnerResults/index.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js create mode 100644 packages/bruno-electron/src/ipc/network/helper.js diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index edc3fb372..cd0cf14b4 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -12,6 +12,7 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import RequestNotFound from './RequestNotFound'; import QueryUrl from 'components/RequestPane/QueryUrl'; import NetworkError from 'components/ResponsePane/NetworkError'; +import RunnerResults from 'components/RunnerResults'; import { DocExplorer } from '@usebruno/graphql-docs'; import StyledWrapper from './StyledWrapper'; @@ -112,6 +113,11 @@ const RequestTabPanel = () => { return
Collection not found!
; } + const showRunner = collection.showRunner; + if(showRunner) { + return ; + } + const item = findItemInCollection(collection, activeTabUid); if (!item || !item.uid) { return ; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js index 15a96a7db..d804e5da9 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js @@ -1,10 +1,20 @@ import React from 'react'; -import { IconFiles } from '@tabler/icons'; +import { IconFiles, IconRun } from '@tabler/icons'; import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; import VariablesView from 'components/VariablesView'; +import { useDispatch } from 'react-redux'; +import { toggleRunnerView } from 'providers/ReduxStore/slices/collections'; import StyledWrapper from './StyledWrapper'; const CollectionToolBar = ({ collection }) => { + const dispatch = useDispatch(); + + const handleRun = () => { + dispatch(toggleRunnerView({ + collectionUid: collection.uid + })); + }; + return (
@@ -13,6 +23,9 @@ const CollectionToolBar = ({ collection }) => { {collection.name}
+ + +
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 989c701d3..7dbaca53e 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react'; import find from 'lodash/find'; import filter from 'lodash/filter'; import classnames from 'classnames'; -import { IconHome2, IconChevronRight, IconChevronLeft } from '@tabler/icons'; +import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { focusTab } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; @@ -76,6 +76,8 @@ const RequestTabs = () => { }); }; + const showRunner = activeCollection && activeCollection.showRunner; + // Todo: Must support ephermal requests return ( @@ -83,59 +85,61 @@ const RequestTabs = () => { {collectionRequestTabs && collectionRequestTabs.length ? ( <> -
-
    - {showChevrons ? ( -
  • -
    - + {!showRunner ? ( +
    +
      + {showChevrons ? ( +
    • +
      + +
      +
    • + ) : null} + {/* Moved to post mvp */} + {/*
    • +
      +
      -
    • - ) : null} - {/* Moved to post mvp */} - {/*
    • -
      - -
      -
    • */} -
    -
      - {collectionRequestTabs && collectionRequestTabs.length - ? collectionRequestTabs.map((tab, index) => { - return ( -
    • handleClick(tab)}> - -
    • - ); - }) - : null} -
    +
  • */} +
+
    + {collectionRequestTabs && collectionRequestTabs.length + ? collectionRequestTabs.map((tab, index) => { + return ( +
  • handleClick(tab)}> + +
  • + ); + }) + : null} +
-
    - {showChevrons ? ( -
  • +
      + {showChevrons ? ( +
    • +
      + +
      +
    • + ) : null} +
    • - + + +
    • - ) : null} -
    • -
      - - - -
      -
    • - {/* Moved to post mvp */} - {/*
    • -
      - - - -
      -
    • */} -
    -
+ {/* Moved to post mvp */} + {/*
  • +
    + + + +
    +
  • */} + + + ) : null} ) : null}
    diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 1ef444090..1dd05c8c5 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -6,13 +6,18 @@ import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; -const QueryResult = ({ item, collection, value, width }) => { +const QueryResult = ({ item, collection, value, width, disableRunEventListener }) => { const { storedTheme } = useTheme(); const dispatch = useDispatch(); - const onRun = () => dispatch(sendRequest(item, collection.uid)); + const onRun = () => { + if(disableRunEventListener) { + return; + } + dispatch(sendRequest(item, collection.uid)); + }; return ( diff --git a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js new file mode 100644 index 000000000..651819928 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js @@ -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 ( +
    +
    Tests
    + {numberOfFailedTests ? ( + + {numberOfFailedTests} + + ) : ( + + {numberOfTests} + + )} +
    + ); +}; + +export default TestResultsLabel; diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js index 4930794b5..3b2fd3f4f 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js @@ -3,12 +3,13 @@ import forOwn from 'lodash/forOwn'; import { safeStringifyJSON } from 'utils/common'; import StyledWrapper from './StyledWrapper'; -const Timeline = ({ item }) => { - const request = item.requestSent || {}; - const response = item.response || {}; +const Timeline = ({ request, response}) => { const requestHeaders = []; const responseHeaders = response.headers || []; + request = request || {}; + response = response || {}; + forOwn(request.headers, (value, key) => { requestHeaders.push({ name: key, diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index 79b64db2d..7a62b3b6f 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -1,6 +1,7 @@ import React from 'react'; import find from 'lodash/find'; import classnames from 'classnames'; +import { safeStringifyJSON } from 'utils/common'; import { useSelector, useDispatch } from 'react-redux'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import QueryResult from './QueryResult'; @@ -12,32 +13,9 @@ import ResponseTime from './ResponseTime'; import ResponseSize from './ResponseSize'; import Timeline from './Timeline'; import TestResults from './TestResults'; +import TestResultsLabel from './TestResultsLabel'; 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 ( -
    -
    Tests
    - {numberOfFailedTests ? ( - - {numberOfFailedTests} - - ) : ( - - {numberOfTests} - - )} -
    - ); -}; - const ResponsePane = ({ rightPaneWidth, item, collection }) => { const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); @@ -62,14 +40,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => { item={item} collection={collection} width={rightPaneWidth} - value={response.data ? JSON.stringify(response.data, null, 2) : '' - } />; + value={response.data ? safeStringifyJSON(response.data, true) : ''} + />; } case 'headers': { return ; } case 'timeline': { - return ; + return ; } case 'tests': { return ; diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js new file mode 100644 index 000000000..0b49d66ca --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js new file mode 100644 index 000000000..8096579cc --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js @@ -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 ; + } + case 'headers': { + return ; + } + case 'timeline': { + return ; + } + case 'tests': { + return ; + } + + default: { + return
    404 | Not found
    ; + } + } + }; + + const getTabClassname = (tabName) => { + return classnames(`tab select-none ${tabName}`, { + active: tabName === selectedTab + }); + }; + + return ( + +
    +
    selectTab('response')}> + Response +
    +
    selectTab('headers')}> + Headers +
    +
    selectTab('timeline')}> + Timeline +
    +
    selectTab('tests')}> + +
    +
    + + + +
    +
    +
    {getTabPanel(selectedTab)}
    +
    + ); +}; + +export default ResponsePane; diff --git a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js new file mode 100644 index 000000000..d2aebd5a8 --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RunnerResults/index.js b/packages/bruno-app/src/components/RunnerResults/index.js new file mode 100644 index 000000000..eafffec47 --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/index.js @@ -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 ( + +
    + Runner + +
    +
    +
    + {items.map((item) => { + return ( +
    +
    +
    + + {item.testStatus === 'pass' ? ( + + ) : ( + + )} + + {item.relativePath} + {item.status !== "completed" ? ( + + ) : ( + setSelectedItem(item)}> + ( + {get(item.responseReceived, 'status')} + + + {get(item.responseReceived, 'statusText')} + ) + + )} +
    + +
      + {item.testResults ? item.testResults.map((result) => ( +
    • + {result.status === 'pass' ? ( + + + {result.description} + + ) : ( + <> + + + {result.description} + + + {result.error} + + + )} +
    • + )): null} +
    +
    +
    + ); + })} +
    +
    + {selectedItem ? ( +
    +
    + {selectedItem.relativePath} + + {selectedItem.testStatus === 'pass' ? ( + + ) : ( + + )} + +
    + {/*
    {selectedItem.relativePath}
    */} + +
    + ) : null} +
    +
    +
    + ); +}; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js new file mode 100644 index 000000000..dcb3fd039 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + .bruno-modal-content { + padding-bottom: 1rem; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js new file mode 100644 index 000000000..194bb3842 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -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 ( + + +
    + Run + ({runLength.length} requests) +
    +
    + This will only run the requests in this folder. +
    + +
    + Recursive Run + ({recursiveRunLength.length} requests) +
    +
    + This will run all the requests in this folder and all its subfolders. +
    + +
    + + + + + + + + + +
    +
    +
    + ); +}; + +export default RunCollectionItem; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 30830d5fb..818e620e5 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -15,6 +15,7 @@ import RequestMethod from './RequestMethod'; import RenameCollectionItem from './RenameCollectionItem'; import CloneCollectionItem from './CloneCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem'; +import RunCollectionItem from './RunCollectionItem'; import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { getDefaultRequestPaneTab } from 'utils/collections'; @@ -33,6 +34,7 @@ const CollectionItem = ({ item, collection, searchText }) => { const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); + const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed); const [{ isDragging }, drag] = useDrag({ @@ -151,6 +153,7 @@ const CollectionItem = ({ item, collection, searchText }) => { {deleteItemModalOpen && setDeleteItemModalOpen(false)} />} {newRequestModalOpen && setNewRequestModalOpen(false)} />} {newFolderModalOpen && setNewFolderModalOpen(false)} />} + {runCollectionModalOpen && setRunCollectionModalOpen(false)} />}
    drag(drop(node))}>
    {indents && indents.length @@ -211,6 +214,15 @@ const CollectionItem = ({ item, collection, searchText }) => { > New Folder
    +
    { + dropdownTippyRef.current.hide(); + setRunCollectionModalOpen(true); + }} + > + Run +
    )}
    { dispatch(collectionRenamedEvent(val)); }; + const _runFolderEvent = (val) => { + dispatch(runFolderEvent(val)); + }; + ipcRenderer.invoke('renderer:ready'); const removeListener1 = ipcRenderer.on('main:collection-opened', _openCollection); @@ -116,6 +121,7 @@ const useCollectionTreeSync = () => { const removeListener7 = ipcRenderer.on('main:http-request-queued', _httpRequestQueued); const removeListener8 = ipcRenderer.on('main:test-results', _testResults); const removeListener9 = ipcRenderer.on('main:collection-renamed', _collectionRenamed); + const removeListener10 = ipcRenderer.on('main:run-folder-event', _runFolderEvent); return () => { removeListener1(); @@ -127,6 +133,7 @@ const useCollectionTreeSync = () => { removeListener7(); removeListener8(); removeListener9(); + removeListener10(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index d51fc30d6..9b7c16125 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -24,6 +24,7 @@ import { saveCollectionToIdb } from 'utils/idb'; import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; import { + resetRunResults, requestCancelled, responseReceived, newItem as _newItem, @@ -138,6 +139,35 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) => .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) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -682,7 +712,8 @@ export const openCollectionEvent = (uid, pathname, name) => (dispatch, getState) uid: uid, name: name, pathname: pathname, - items: [] + items: [], + showRunner: false }; return new Promise((resolve, reject) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index a2020bc3a..dbd2b080f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -849,6 +849,59 @@ export const collectionsSlice = createSlice({ if (collection) { 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, collectionAddEnvFileEvent, testResultsEvent, - collectionRenamedEvent + collectionRenamedEvent, + toggleRunnerView, + resetRunResults, + runFolderEvent } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 20afbc5f1..191fefc2b 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -36,11 +36,14 @@ export const safeParseJSON = (str) => { } }; -export const safeStringifyJSON = (obj) => { +export const safeStringifyJSON = (obj, indent=false) => { if(!obj) { return obj; } try { + if(indent) { + return JSON.stringify(obj, null, 2); + } return JSON.stringify(obj); } catch (e) { return obj; diff --git a/packages/bruno-electron/src/ipc/network/helper.js b/packages/bruno-electron/src/ipc/network/helper.js new file mode 100644 index 000000000..6be61ea98 --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/helper.js @@ -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 +}; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 4312c55be..92f075542 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -9,6 +9,10 @@ const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-requ const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token'); const { uuid } = require('../../utils/common'); const interpolateVars = require('./interpolate-vars'); +const { + sortFolder, + getAllRequestsInFolderRecursively +} = require('./helper'); // override the default escape function to prevent escaping Mustache.escape = function (value) { @@ -47,6 +51,22 @@ const getEnvVars = (environment = {}) => { 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) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collectionUid, collectionPath, environment) => { @@ -197,6 +217,141 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => { 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; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 948de4094..5563d2783 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -85,7 +85,11 @@ const collectionSchema = Yup.object({ .matches(/^[a-zA-Z0-9]*$/, 'uid must be alphanumeric') .nullable(), environments: environmentsSchema, - pathname: Yup.string().nullable() + pathname: Yup.string().nullable(), + showRunner: Yup.boolean(), + runnerResult: Yup.object({ + items: Yup.array() + }) }).noUnknown(true).strict();