diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aec3d68a0..c029b0224 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,9 @@ jobs: cli-test: name: CLI Tests runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/package-lock.json b/package-lock.json index 98b220863..f53c786a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24030,7 +24030,6 @@ "@usebruno/common": "0.1.0", "@usebruno/graphql-docs": "0.1.0", "@usebruno/schema": "0.7.0", - "axios": "1.7.5", "classnames": "^2.3.1", "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", @@ -24147,7 +24146,7 @@ "@usebruno/lang": "0.12.0", "@usebruno/vm2": "^3.9.13", "aws4-axios": "^3.3.0", - "axios": "1.7.5", + "axios": "1.7.7", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chalk": "^3.0.0", @@ -24168,6 +24167,16 @@ "bru": "bin/bru.js" } }, + "packages/bruno-cli/node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-cli/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -24210,7 +24219,7 @@ "@usebruno/vm2": "^3.9.13", "about-window": "^1.15.2", "aws4-axios": "^3.3.0", - "axios": "1.7.5", + "axios": "1.7.7", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chokidar": "^3.5.3", @@ -24247,6 +24256,17 @@ "dmg-license": "^1.0.11" } }, + "packages/bruno-electron/node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-electron/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -24341,7 +24361,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "atob": "^2.1.2", - "axios": "1.7.5", + "axios": "1.7.7", "btoa": "^1.2.1", "chai": "^4.3.7", "chai-string": "^1.5.0", @@ -24368,6 +24388,16 @@ "@usebruno/vm2": "^3.9.13" } }, + "packages/bruno-js/node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-js/node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -24445,7 +24475,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "axios": "1.7.5", + "axios": "1.7.7", "body-parser": "1.20.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", @@ -24459,6 +24489,16 @@ "multer": "^1.4.5-lts.1" } }, + "packages/bruno-tests/node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "packages/bruno-tests/node_modules/fast-xml-parser": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz", diff --git a/package.json b/package.json index 2c46fdd2c..b105c1e0b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "build:web": "npm run build --workspace=packages/bruno-app", "prettier:web": "npm run prettier --workspace=packages/bruno-app", "dev:electron": "npm run dev --workspace=packages/bruno-electron", + "dev:electron:debug": "npm run debug --workspace=packages/bruno-electron", "build:bruno-common": "npm run build --workspace=packages/bruno-common", "build:bruno-query": "npm run build --workspace=packages/bruno-query", "build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index d4fb47bcf..c824f13a8 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -20,7 +20,6 @@ "@usebruno/common": "0.1.0", "@usebruno/graphql-docs": "0.1.0", "@usebruno/schema": "0.7.0", - "axios": "1.7.5", "classnames": "^2.3.1", "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index edcee4cd9..9573022a4 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -8,6 +8,8 @@ const StyledWrapper = styled.div` font-size: ${(props) => (props.fontSize ? `${props.fontSize}px` : 'inherit')}; line-break: anywhere; flex: 1 1 0; + display: flex; + flex-direction: column-reverse; } /* Removes the glow outline around the folded json */ @@ -26,6 +28,10 @@ const StyledWrapper = styled.div` .CodeMirror-dialog { overflow: visible; + position: relative; + top: unset; + left: unset; + input { background: transparent; border: 1px solid #d3d6db; diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 398007a4a..d589e6436 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -194,8 +194,20 @@ export default class CodeEditor extends React.Component { 'Cmd-Y': 'foldAll', 'Ctrl-I': 'unfoldAll', 'Cmd-I': 'unfoldAll', - 'Ctrl-/': 'toggleComment', - 'Cmd-/': 'toggleComment' + 'Ctrl-/': () => { + if (['application/ld+json', 'application/json'].includes(this.props.mode)) { + this.editor.toggleComment({ lineComment: '//', blockComment: '/*' }); + } else { + this.editor.toggleComment(); + } + }, + 'Cmd-/': () => { + if (['application/ld+json', 'application/json'].includes(this.props.mode)) { + this.editor.toggleComment({ lineComment: '//', blockComment: '/*' }); + } else { + this.editor.toggleComment(); + } + } }, foldOptions: { widget: (from, to) => { diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js index e49220854..b7e4b56c7 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js @@ -1,5 +1,7 @@ import styled from 'styled-components'; -const Wrapper = styled.div``; +const Wrapper = styled.div` + max-width: 800px; +`; export default Wrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js index 262f068e7..afe08bcba 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js @@ -8,7 +8,6 @@ const StyledWrapper = styled.div` } .editing-mode { cursor: pointer; - color: ${(props) => props.theme.colors.text.yellow}; } `; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 23dbe9e70..2d869de65 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -8,6 +8,7 @@ import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/acti import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; import StyledWrapper from './StyledWrapper'; +import { IconEdit, IconX, IconFileText } from '@tabler/icons'; const Docs = ({ collection }) => { const dispatch = useDispatch(); @@ -29,35 +30,95 @@ const Docs = ({ collection }) => { ); }; - const onSave = () => dispatch(saveCollectionRoot(collection.uid)); + const handleDiscardChanges = () => { + dispatch( + updateCollectionDocs({ + collectionUid: collection.uid, + docs: docs + }) + ); + toggleViewMode(); + } + + const onSave = () => { + dispatch(saveCollectionRoot(collection.uid)); + toggleViewMode(); + } return ( -
- {isEditing ? 'Preview' : 'Edit'} -
- - {isEditing ? ( -
- - +
+
+ + Documentation
+
+ {isEditing ? ( + <> +
+ +
+ + + ) : ( +
+ +
+ )} +
+
+ {isEditing ? ( + ) : ( - +
+
+ { + docs?.length > 0 ? + + : + + } +
+
)} ); }; export default Docs; + + +const documentationPlaceholder = ` +Welcome to your collection documentation! This space is designed to help you document your API collection effectively. + +## Overview +Use this section to provide a high-level overview of your collection. You can describe: +- The purpose of these API endpoints +- Key features and functionalities +- Target audience or users + +## Best Practices +- Keep documentation up to date +- Include request/response examples +- Document error scenarios +- Add relevant links and references + +## Markdown Support +This documentation supports Markdown formatting! You can use: +- **Bold** and *italic* text +- \`code blocks\` and syntax highlighting +- Tables and lists +- [Links](https://example.com) +- And more! +`; diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js index 9f723cb81..c4d03c5ed 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const Wrapper = styled.div` + max-width: 800px; + table { width: 100%; border-collapse: collapse; diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js deleted file mode 100644 index 7fd98347c..000000000 --- a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js +++ /dev/null @@ -1,13 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - table { - td { - &:first-child { - width: 120px; - } - } - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Info/index.js deleted file mode 100644 index 3b0a1297b..000000000 --- a/packages/bruno-app/src/components/CollectionSettings/Info/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import StyledWrapper from './StyledWrapper'; -import { getTotalRequestCountInCollection } from 'utils/collections/'; - -const Info = ({ collection }) => { - const totalRequestsInCollection = getTotalRequestCountInCollection(collection); - - return ( - -
General information about the collection.
- - - - - - - - - - - - - - - - - - - - - - - -
Name :{collection.name}
Location :{collection.pathname}
Ignored files :{collection.brunoConfig?.ignore?.map((x) => `'${x}'`).join(', ')}
Environments :{collection.environments?.length || 0}
Requests :{totalRequestsInCollection}
-
- ); -}; - -export default Info; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js new file mode 100644 index 000000000..86bf2308f --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { getTotalRequestCountInCollection } from 'utils/collections/'; +import { IconFolder, IconFileOff, IconWorld, IconApi } from '@tabler/icons'; + +const Info = ({ collection }) => { + const totalRequestsInCollection = getTotalRequestCountInCollection(collection); + + return ( +
+
+
+ {/* Location Row */} +
+
+ +
+
+
Location
+
+ {collection.pathname} +
+
+
+ + {/* Environments Row */} +
+
+ +
+
+
Environments
+
+ {collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured +
+
+
+ + {/* Requests Row */} +
+
+ +
+
+
Requests
+
+ {totalRequestsInCollection} request{totalRequestsInCollection !== 1 ? 's' : ''} in collection +
+
+
+
+
+
+ ); +}; + +export default Info; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js new file mode 100644 index 000000000..e9a9cd06f --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + &.card { + background-color: ${(props) => props.theme.requestTabPanel.card.bg}; + + .title { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + border-left: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + border-right: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + + border-top-left-radius: 3px; + border-top-right-radius: 3px; + } + + .table { + thead { + background-color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.bg}; + color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.color}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js new file mode 100644 index 000000000..c15b36cd8 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { flattenItems } from "utils/collections"; +import { IconAlertTriangle } from '@tabler/icons'; +import StyledWrapper from "./StyledWrapper"; + +const RequestsNotLoaded = ({ collection }) => { + const flattenedItems = flattenItems(collection.items); + const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading); + + if (!itemsFailedLoading?.length) { + return null; + } + + return ( + +
+ + Following requests were not loaded +
+ + + + + + + + + {flattenedItems?.map((item, index) => ( + item?.partial && !item?.loading ? ( + + + + + ) : null + ))} + +
+ Pathname + + Size +
+ {item?.pathname?.split(`${collection?.pathname}/`)?.[1]} + + {item?.size?.toFixed?.(2)} MB +
+
+ ); +}; + +export default RequestsNotLoaded; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js new file mode 100644 index 000000000..4d77f2600 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .partial { + color: ${(props) => props.theme.colors.text.yellow}; + opacity: 0.8; + } + + .loading { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.8; + } + + .completed { + color: ${(props) => props.theme.colors.text.green}; + opacity: 0.8; + } + + .failed { + color: ${(props) => props.theme.colors.text.danger}; + opacity: 0.8; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js new file mode 100644 index 000000000..87b461e9c --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js @@ -0,0 +1,27 @@ +import StyledWrapper from "./StyledWrapper"; +import Docs from "../Docs"; +import Info from "./Info"; +import { IconBox } from '@tabler/icons'; +import RequestsNotLoaded from "./RequestsNotLoaded"; + +const Overview = ({ collection }) => { + return ( +
+
+
+
+ + {collection?.name} +
+ + +
+
+ +
+
+
+ ); +} + +export default Overview; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js index 602851baa..db26e863b 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + .settings-label { width: 110px; } diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js index 66ba1ed3d..03aed74aa 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + div.CodeMirror { height: inherit; } diff --git a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js index b88a31e0d..90ab7fee5 100644 --- a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js @@ -1,8 +1,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - max-width: 800px; - div.tabs { div.tab { padding: 6px 0px; diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js index ec278887d..b9014ebd5 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js @@ -1,5 +1,7 @@ import styled from 'styled-components'; -const StyledWrapper = styled.div``; +const StyledWrapper = styled.div` + max-width: 800px; +`; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js index 44b01b464..26459a3c6 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + div.title { color: var(--color-tab-inactive); } diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index b849d6b18..7d5d60574 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -12,12 +12,11 @@ import Headers from './Headers'; import Auth from './Auth'; import Script from './Script'; import Test from './Tests'; -import Docs from './Docs'; import Presets from './Presets'; -import Info from './Info'; import StyledWrapper from './StyledWrapper'; import Vars from './Vars/index'; import DotIcon from 'components/Icons/Dot'; +import Overview from './Overview/index'; const ContentIndicator = () => { return ( @@ -97,6 +96,9 @@ const CollectionSettings = ({ collection }) => { const getTabPanel = (tab) => { switch (tab) { + case 'overview': { + return ; + } case 'headers': { return ; } @@ -128,12 +130,6 @@ const CollectionSettings = ({ collection }) => { /> ); } - case 'docs': { - return ; - } - case 'info': { - return ; - } } }; @@ -146,6 +142,9 @@ const CollectionSettings = ({ collection }) => { return (
+
setTab('overview')}> + Overview +
setTab('headers')}> Headers {activeHeadersCount > 0 && {activeHeadersCount}} @@ -177,13 +176,6 @@ const CollectionSettings = ({ collection }) => { Client Certificates {clientCertConfig.length > 0 && }
-
setTab('docs')}> - Docs - {hasDocs && } -
-
setTab('info')}> - Info -
{getTabPanel(tab)}
diff --git a/packages/bruno-app/src/components/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/Documentation/StyledWrapper.js index f159d94dc..af80d4c08 100644 --- a/packages/bruno-app/src/components/Documentation/StyledWrapper.js +++ b/packages/bruno-app/src/components/Documentation/StyledWrapper.js @@ -3,7 +3,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` .editing-mode { cursor: pointer; - color: ${(props) => props.theme.colors.text.yellow}; } `; diff --git a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js index fa1269e14..0ac61b4e5 100644 --- a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js +++ b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js @@ -9,7 +9,6 @@ const StyledMarkdownBodyWrapper = styled.div` box-sizing: border-box; height: 100%; margin: 0 auto; - padding-top: 0.5rem; font-size: 0.875rem; h1 { @@ -80,12 +79,6 @@ const StyledMarkdownBodyWrapper = styled.div` } } } - - @media (max-width: 767px) { - .markdown-body { - padding: 15px; - } - } `; export default StyledMarkdownBodyWrapper; diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js index 3ee08cbb9..0b44b928b 100644 --- a/packages/bruno-app/src/components/Modal/index.js +++ b/packages/bruno-app/src/components/Modal/index.js @@ -62,7 +62,7 @@ const Modal = ({ confirmText, cancelText, handleCancel, - handleConfirm, + handleConfirm = () => {}, children, confirmDisabled, hideCancel, @@ -92,7 +92,7 @@ const Modal = ({ }; useFocusTrap(modalRef); - + const closeModal = (args) => { setIsClosing(true); setTimeout(() => handleCancel(args), closeModalFadeTimeout); @@ -103,7 +103,7 @@ const Modal = ({ return () => { document.removeEventListener('keydown', handleKeydown); }; - }, [disableEscapeKey, document]); + }, [disableEscapeKey, document, handleConfirm]); let classes = 'bruno-modal'; if (isClosing) { diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js new file mode 100644 index 000000000..ff6c48575 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.card { + background: ${(props) => props.theme.requestTabPanel.card.bg}; + border: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + + div.hr { + border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr}; + height: 1px; + } + + div.border-top { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js new file mode 100644 index 000000000..9d2ff1346 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js @@ -0,0 +1,47 @@ +import { IconLoader2, IconFile } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const RequestIsLoading = ({ item }) => { + return +
+
+
+
+ + File Info +
+
+ +
+ Name: +
+ {item?.name} +
+
+ +
+ Path: +
+ {item?.pathname} +
+
+ +
+ Size: +
+ {item?.size?.toFixed?.(2)} MB +
+
+ +
+
+ + Loading... +
+
+
+
+ +} + +export default RequestIsLoading; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js new file mode 100644 index 000000000..ff6c48575 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.card { + background: ${(props) => props.theme.requestTabPanel.card.bg}; + border: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + + div.hr { + border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr}; + height: 1px; + } + + div.border-top { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js new file mode 100644 index 000000000..1a951b624 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js @@ -0,0 +1,89 @@ +import { IconLoader2, IconFile } from '@tabler/icons'; +import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; + +const RequestNotLoaded = ({ collection, item }) => { + const dispatch = useDispatch(); + const handleLoadRequestViaWorker = () => { + !item?.loading && dispatch(loadRequestViaWorker({ collectionUid: collection?.uid, pathname: item?.pathname })); + } + + const handleLoadRequest = () => { + !item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname })); + } + + return +
+
+
+
+ + File Info +
+
+ +
+ Name: +
{item?.name}
+
+ +
+ Path: +
{item?.pathname}
+
+ +
+ Size: +
{item?.size?.toFixed?.(2)} MB
+
+ + {!item?.error && ( + <> +
+
+ Due to its large size, this request wasn't loaded automatically. +
+
+
+ + + May cause the app to freeze temporarily while it runs. + +
+
+ + + Runs in background. + +
+
+ + )} + + {item?.loading && ( + <> +
+
+ + Loading... +
+ + )} +
+
+
+ +} + +export default RequestNotLoaded; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 4bcfff1c3..d7690e08a 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -22,6 +22,9 @@ import SecuritySettings from 'components/SecuritySettings'; import FolderSettings from 'components/FolderSettings'; import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; import { produce } from 'immer'; +import CollectionOverview from 'components/CollectionSettings/Overview'; +import RequestNotLoaded from './RequestNotLoaded'; +import RequestIsLoading from './RequestIsLoading'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; @@ -153,6 +156,11 @@ const RequestTabPanel = () => { if (focusedTab.type === 'collection-settings') { return ; } + + if (focusedTab.type === 'collection-overview') { + return ; + } + if (focusedTab.type === 'folder-settings') { const folder = findItemInCollection(collection, focusedTab.folderUid); return ; @@ -167,6 +175,14 @@ const RequestTabPanel = () => { return ; } + if (item?.partial) { + return + } + + if (item?.loading) { + return + } + const handleRun = async () => { dispatch(sendRequest(item, collection.uid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index c5d09faa8..1cbb0aa05 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -13,6 +13,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => { ); } + case 'collection-overview': { + return ( + <> + + Collection + + ); + } case 'security-settings': { return ( <> diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index e73313c13..2d74a4290 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -70,7 +70,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }; const folder = folderUid ? findItemInCollection(collection, folderUid) : null; - if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { + if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { return ( { // convert to unix style path @@ -106,6 +107,8 @@ export default function RunnerResults({ collection }) { return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail'; }); + let isCollectionLoading = areItemsLoading(collection); + if (!items || !items.length) { return ( @@ -116,7 +119,7 @@ export default function RunnerResults({ collection }) {
You have {totalRequestsInCollection} requests in this collection.
- + {isCollectionLoading ?
Requests in this collection are still loading.
: null}
props.theme.colors.text.yellow}; + } + .error { + color: ${(props) => props.theme.colors.text.danger}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js new file mode 100644 index 000000000..82d87aa7d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js @@ -0,0 +1,21 @@ +import RequestMethod from "../RequestMethod"; +import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons'; +import StyledWrapper from "./StyledWrapper"; + +const CollectionItemIcon = ({ item }) => { + if (item?.error) { + return ; + } + + if (item?.loading) { + return ; + } + + if (item?.partial) { + return ; + } + + return ; +}; + +export default CollectionItemIcon; \ 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 index 3b6e08f42..e7dd94d2f 100644 --- 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 @@ -4,6 +4,9 @@ const Wrapper = styled.div` .bruno-modal-content { padding-bottom: 1rem; } + .warning { + color: ${(props) => props.theme.colors.text.danger}; + } `; 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 index 4a81f59af..cfd236f8c 100644 --- 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 @@ -7,6 +7,7 @@ import { addTab } from 'providers/ReduxStore/slices/tabs'; import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions'; import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; +import { areItemsLoading } from 'utils/collections'; const RunCollectionItem = ({ collection, item, onClose }) => { const dispatch = useDispatch(); @@ -32,6 +33,10 @@ const RunCollectionItem = ({ collection, item, onClose }) => { const flattenedItems = flattenItems(item ? item.items : collection.items); const recursiveRunLength = getRequestsCount(flattenedItems); + const isFolderLoading = areItemsLoading(item); + console.log(item); + console.log(isFolderLoading); + return ( @@ -44,13 +49,12 @@ const RunCollectionItem = ({ collection, item, onClose }) => { ({runLength} requests)
This will only run the requests in this folder.
-
Recursive Run ({recursiveRunLength} requests)
-
This will run all the requests in this folder and all its subfolders.
- +
This will run all the requests in this folder and all its subfolders.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null}
- + {item.name} @@ -378,6 +378,15 @@ const CollectionItem = ({ item, collection, searchText }) => { Generate Code
)} +
{ + dropdownTippyRef.current.hide(); + handleShowInFolder(); + }} + > + Show in Folder +
{ @@ -421,4 +430,4 @@ const CollectionItem = ({ item, collection, searchText }) => { ); }; -export default CollectionItem; +export default CollectionItem; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 4642d58f8..c99f85693 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -3,10 +3,10 @@ import classnames from 'classnames'; import { uuid } from 'utils/common'; import filter from 'lodash/filter'; import { useDrop } from 'react-dnd'; -import { IconChevronRight, IconDots } from '@tabler/icons'; +import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; -import { collectionClicked } from 'providers/ReduxStore/slices/collections'; -import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { collapseCollection } from 'providers/ReduxStore/slices/collections'; +import { mountCollection, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch } from 'react-redux'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; @@ -15,12 +15,12 @@ import CollectionItem from './CollectionItem'; import RemoveCollection from './RemoveCollection'; import ExportCollection from './ExportCollection'; import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; -import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections'; -import exportCollection from 'utils/collections/export'; +import { isItemAFolder, isItemARequest } from 'utils/collections'; import RenameCollection from './RenameCollection'; import StyledWrapper from './StyledWrapper'; -import CloneCollection from './CloneCollection/index'; +import CloneCollection from './CloneCollection'; +import { areItemsLoading } from 'utils/collections'; const Collection = ({ collection, searchText }) => { const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -29,8 +29,8 @@ const Collection = ({ collection, searchText }) => { const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); const [showExportCollectionModal, setShowExportCollectionModal] = useState(false); const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); - const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed); const dispatch = useDispatch(); + const isLoading = areItemsLoading(collection); const menuDropdownTippyRef = useRef(); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); @@ -52,32 +52,37 @@ const Collection = ({ collection, searchText }) => { ); }; - useEffect(() => { - if (searchText && searchText.length) { - setCollectionIsCollapsed(false); - } else { - setCollectionIsCollapsed(collection.collapsed); - } - }, [searchText, collection]); + const hasSearchText = searchText && searchText?.trim()?.length; + const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; const iconClassName = classnames({ 'rotate-90': !collectionIsCollapsed }); const handleClick = (event) => { - dispatch(collectionClicked(collection.uid)); - }; + // Check if the click came from the chevron icon + const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon'); - const handleCollapseCollection = () => { - dispatch(collectionClicked(collection.uid)); - dispatch( - addTab({ - uid: uuid(), + if (collection.mountStatus === 'unmounted') { + dispatch(mountCollection({ collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - } + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + } + dispatch(collapseCollection(collection.uid)); + + // Only open collection settings if not clicking the chevron + if(!isChevronClick) { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + } + }; const handleRightClick = (event) => { const _menuDropdown = menuDropdownTippyRef.current; @@ -154,9 +159,8 @@ const Collection = ({ collection, searchText }) => { + {isLoading ? : null}
} placement="bottom-start"> diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js index 6c4031729..47f0f553e 100644 --- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js @@ -68,7 +68,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => { ); }; return ( - +

Select the type of your existing collection :

diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 4163ffc37..50e19c22e 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -184,7 +184,7 @@ const Sidebar = () => { Star */}
-
v1.36.0
+
v1.36.1
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 75c6f2cb9..cc53b3339 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -21,8 +21,9 @@ import { transformRequestToSaveToFilesystem } from 'utils/collections'; import { uuid, waitForNextTick } from 'utils/common'; -import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform'; +import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform'; import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network'; +import { callIpc } from 'utils/common/ipc'; import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, @@ -30,6 +31,7 @@ import { removeCollection as _removeCollection, selectEnvironment as _selectEnvironment, sortCollections as _sortCollections, + updateCollectionMountStatus, requestCancelled, resetRunResults, responseReceived, @@ -42,7 +44,6 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; -import { name } from 'file-loader'; import slash from 'utils/common/slash'; import { getGlobalEnvironmentVariables } from 'utils/collections/index'; import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; @@ -161,7 +162,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) if (!folder) { return reject(new Error('Folder not found')); } - console.log(collection); const { ipcRenderer } = window; @@ -170,7 +170,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) pathname: folder.pathname, root: folder.root }; - console.log(folderData); ipcRenderer .invoke('renderer:save-folder-root', folderData) @@ -494,7 +493,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat ); if (!reqWithSameNameExists) { const dirname = getDirectoryName(item.pathname); - const fullName = path.join(dirname, filename); + const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename); const { ipcRenderer } = window; const requestItems = filter(parentItem.items, (i) => i.type !== 'folder'); itemToSave.seq = requestItems ? requestItems.length + 1 : 1; @@ -1192,4 +1191,38 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS reject(error); } }); - }; \ No newline at end of file + }; + +export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject); + }); +}; + +export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject); + }); +}; + +export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig }) => (dispatch, getState) => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); + return new Promise(async (resolve, reject) => { + callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) + .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }))) + .then(resolve) + .catch(() => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' })); + reject(); + }); + }); +}; + + export const showInFolder = (collectionPath) => () => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(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 11f12026f..6a795171f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -4,7 +4,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { addDepth, areItemsTheSameExceptSeqUpdate, - collapseCollection, + collapseAllItemsInCollection, deleteItemInCollection, deleteItemInCollectionByPathname, findCollectionByPathname, @@ -32,9 +32,13 @@ export const collectionsSlice = createSlice({ const collectionUids = map(state.collections, (c) => c.uid); const collection = action.payload; - collection.settingsSelectedTab = 'headers'; + collection.settingsSelectedTab = 'overview'; collection.folderLevelSettingsSelectedTab = {}; + // Collection mount status is used to track the mount status of the collection + // values can be 'unmounted', 'mounting', 'mounted' + collection.mountStatus = 'unmounted'; + // TODO: move this to use the nextAction approach // last action is used to track the last action performed on the collection // this is optional @@ -44,12 +48,18 @@ export const collectionsSlice = createSlice({ collection.importedAt = new Date().getTime(); collection.lastAction = null; - collapseCollection(collection); + collapseAllItemsInCollection(collection); addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { state.collections.push(collection); } }, + updateCollectionMountStatus: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (collection) { + collection.mountStatus = action.payload.mountStatus; + } + }, setCollectionSecurityConfig: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -358,7 +368,7 @@ export const collectionsSlice = createSlice({ collection.items.push(item); } }, - collectionClicked: (state, action) => { + collapseCollection: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload); if (collection) { @@ -1582,7 +1592,7 @@ export const collectionsSlice = createSlice({ name: directoryName, collapsed: true, type: 'folder', - items: [] + items: [], }; currentSubItems.push(childItem); } @@ -1604,6 +1614,10 @@ export const collectionsSlice = createSlice({ currentItem.filename = file.meta.name; currentItem.pathname = file.meta.pathname; currentItem.draft = null; + currentItem.partial = file.partial; + currentItem.loading = file.loading; + currentItem.size = file.size; + currentItem.error = file.error; } else { currentSubItems.push({ uid: file.data.uid, @@ -1613,7 +1627,11 @@ export const collectionsSlice = createSlice({ request: file.data.request, filename: file.meta.name, pathname: file.meta.pathname, - draft: null + draft: null, + partial: file.partial, + loading: file.loading, + size: file.size, + error: file.error }); } } @@ -1890,6 +1908,7 @@ export const collectionsSlice = createSlice({ export const { createCollection, + updateCollectionMountStatus, setCollectionSecurityConfig, brunoConfigUpdateEvent, renameCollection, @@ -1913,7 +1932,7 @@ export const { saveRequest, deleteRequestDraft, newEphemeralHttpRequest, - collectionClicked, + collapseCollection, collectionFolderClicked, requestUrlChanged, updateAuth, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 935be6075..2dfa3d94a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -25,7 +25,7 @@ export const tabsSlice = createSlice({ } if ( - ['variables', 'collection-settings', 'collection-runner', 'security-settings'].includes(action.payload.type) + ['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type) ) { const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type); if (tab) { diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 9e8e923aa..a47abb8d2 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -114,7 +114,25 @@ const darkTheme = { responseStatus: '#ccc', responseOk: '#8cd656', responseError: '#f06f57', - responseOverlayBg: 'rgba(30, 30, 30, 0.6)' + responseOverlayBg: 'rgba(30, 30, 30, 0.6)', + + card: { + bg: '#252526', + border: 'transparent', + borderDark: '#8cd656', + hr: '#424242' + }, + + cardTable: { + border: '#333', + bg: '#252526', + table: { + thead: { + bg: '#3D3D3D', + color: '#ccc' + } + } + } }, collection: { diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index a25583136..9d3439895 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -114,7 +114,22 @@ const lightTheme = { responseStatus: 'rgb(117 117 117)', responseOk: '#047857', responseError: 'rgb(185, 28, 28)', - responseOverlayBg: 'rgba(255, 255, 255, 0.6)' + responseOverlayBg: 'rgba(255, 255, 255, 0.6)', + card: { + bg: '#fff', + border: '#f4f4f4', + hr: '#f4f4f4' + }, + cardTable: { + border: '#efefef', + bg: '#fff', + table: { + thead: { + bg: 'rgb(249, 250, 251)', + color: 'rgb(75 85 99)' + } + } + } }, collection: { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index bc6c731f4..956616710 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -34,7 +34,7 @@ export const addDepth = (items = []) => { depth(items, 1); }; -export const collapseCollection = (collection) => { +export const collapseAllItemsInCollection = (collection) => { collection.collapsed = true; const collapseItem = (items) => { @@ -47,7 +47,7 @@ export const collapseCollection = (collection) => { }); }; - collapseItem(collection.items, 1); + collapseItem(collection.items); }; export const sortItems = (collection) => { @@ -136,6 +136,16 @@ export const findEnvironmentInCollectionByName = (collection, name) => { return find(collection.environments, (e) => e.name === name); }; +export const areItemsLoading = (folder) => { + let flattenedItems = flattenItems(folder.items); + return flattenedItems?.reduce((isLoading, i) => { + if (i?.loading) { + isLoading = true; + } + return isLoading; + }, false); +} + export const moveCollectionItem = (collection, draggedItem, targetItem) => { let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); @@ -991,4 +1001,4 @@ const mergeVars = (collection, requestTreePath = []) => { folderVariables, requestVariables }; -}; +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/ipc.js b/packages/bruno-app/src/utils/common/ipc.js new file mode 100644 index 000000000..3559737f2 --- /dev/null +++ b/packages/bruno-app/src/utils/common/ipc.js @@ -0,0 +1,14 @@ +/** + * Wrapper for ipcRenderer.invoke that handles error cases + * @param {string} channel - The IPC channel name + * @param {...any} args - Arguments to pass to the channel + * @returns {Promise} - Resolves with the result or rejects with error + */ +export const callIpc = (channel, ...args) => { + const { ipcRenderer } = window; + if (!ipcRenderer) { + return Promise.reject(new Error('IPC Renderer not available')); + } + + return ipcRenderer.invoke(channel, ...args); +}; \ No newline at end of file diff --git a/packages/bruno-app/src/utils/common/platform.js b/packages/bruno-app/src/utils/common/platform.js index ddfdb3a1f..c50ded79a 100644 --- a/packages/bruno-app/src/utils/common/platform.js +++ b/packages/bruno-app/src/utils/common/platform.js @@ -24,11 +24,25 @@ export const getSubdirectoriesFromRoot = (rootPath, pathname) => { return relativePath ? relativePath.split(path.sep) : []; }; -export const getDirectoryName = (pathname) => { - // convert to unix style path - pathname = slash(pathname); - return path.dirname(pathname); +export const isWindowsPath = (pathname) => { + + if (!isWindowsOS()) { + return false; + } + + // Check for Windows drive letter format (e.g., "C:\") + const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname); + + // Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path + const isUNCPath = pathname.startsWith('\\\\'); + + return hasDriveLetter || isUNCPath; +}; + + +export const getDirectoryName = (pathname) => { + return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname); }; export const isWindowsOS = () => { diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 8353e8ba8..cfeff6f63 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -52,7 +52,7 @@ "@usebruno/lang": "0.12.0", "@usebruno/vm2": "^3.9.13", "aws4-axios": "^3.3.0", - "axios": "1.7.5", + "axios": "1.7.7", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chalk": "^3.0.0", @@ -66,7 +66,6 @@ "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^4.1.3", - "@usebruno/vm2": "^3.9.13", "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" } diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 29edf63b9..4f82a86e6 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -11,6 +11,7 @@ const { rpad } = require('../utils/common'); const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru'); const { dotenvToJson } = require('@usebruno/lang'); const constants = require('../constants'); +const { findItemInCollection } = require('../utils/collection'); const command = 'run [filename]'; const desc = 'Run a request'; @@ -18,6 +19,7 @@ const printRunSummary = (results) => { let totalRequests = 0; let passedRequests = 0; let failedRequests = 0; + let skippedRequests = 0; let totalAssertions = 0; let passedAssertions = 0; let failedAssertions = 0; @@ -49,7 +51,10 @@ const printRunSummary = (results) => { failedAssertions += 1; } } - if (!hasAnyTestsOrAssertions && result.error) { + if (!hasAnyTestsOrAssertions && result.skipped) { + skippedRequests += 1; + } + else if (!hasAnyTestsOrAssertions && result.error) { failedRequests += 1; } else { passedRequests += 1; @@ -62,6 +67,9 @@ const printRunSummary = (results) => { if (failedRequests > 0) { requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`; } + if (skippedRequests > 0) { + requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`; + } requestSummary += `, ${totalRequests} total`; let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`; @@ -84,6 +92,7 @@ const printRunSummary = (results) => { totalRequests, passedRequests, failedRequests, + skippedRequests, totalAssertions, passedAssertions, failedAssertions, @@ -144,7 +153,7 @@ const createCollectionFromPath = (collectionPath) => { }); } } - return currentDirItems + return currentDirItems; }; collection.items = traverse(collectionPath); return collection; @@ -634,6 +643,34 @@ const handler = async function (argv) { } const runtime = getJsSandboxRuntime(sandbox); + + const runSingleRequestByPathname = async (relativeItemPathname) => { + return new Promise(async (resolve, reject) => { + let itemPathname = path.join(collectionPath, relativeItemPathname); + if (itemPathname && !itemPathname?.endsWith('.bru')) { + itemPathname = `${itemPathname}.bru`; + } + const bruJson = cloneDeep(findItemInCollection(collection, itemPathname)); + if (bruJson) { + const res = await runSingleRequest( + itemPathname, + bruJson, + collectionPath, + runtimeVariables, + envVars, + processEnvVars, + brunoConfig, + collectionRoot, + runtime, + collection, + runSingleRequestByPathname + ); + resolve(res?.response); + } + reject(`bru.runRequest: invalid request path - ${itemPathname}`); + }); + } + let currentRequestIndex = 0; let nJumps = 0; // count the number of jumps to avoid infinite loops while (currentRequestIndex < bruJsons.length) { @@ -651,7 +688,8 @@ const handler = async function (argv) { brunoConfig, collectionRoot, runtime, - collection + collection, + runSingleRequestByPathname ); results.push({ @@ -701,6 +739,11 @@ const handler = async function (argv) { // determine next request const nextRequestName = result?.nextRequestName; + + if (result?.shouldStopRunnerExecution) { + break; + } + if (nextRequestName !== undefined) { nJumps++; if (nJumps > 10000) { diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index f031ff0aa..11963bd29 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -36,7 +36,7 @@ const prepareRequest = (item = {}, collection = {}) => { }; const collectionAuth = get(collection, 'root.request.auth'); - if (collectionAuth && request.auth.mode === 'inherit') { + if (collectionAuth && request.auth?.mode === 'inherit') { if (collectionAuth.mode === 'basic') { axiosRequest.auth = { username: get(collectionAuth, 'basic.username'), @@ -47,9 +47,27 @@ const prepareRequest = (item = {}, collection = {}) => { if (collectionAuth.mode === 'bearer') { axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`; } + + if (collectionAuth.mode === 'apikey') { + if (collectionAuth.apikey?.placement === 'header') { + axiosRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value; + } + + if (collectionAuth.apikey?.placement === 'queryparams') { + if (axiosRequest.url && collectionAuth.apikey?.key) { + try { + const urlObj = new URL(request.url); + urlObj.searchParams.set(collectionAuth.apikey?.key, collectionAuth.apikey?.value); + axiosRequest.url = urlObj.toString(); + } catch (error) { + console.error('Invalid URL:', request.url, error); + } + } + } + } } - if (request.auth) { + if (request.auth && request.auth.mode !== 'inherit') { if (request.auth.mode === 'basic') { axiosRequest.auth = { username: get(request, 'auth.basic.username'), diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 67bda8021..021980dfd 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -40,11 +40,13 @@ const runSingleRequest = async function ( brunoConfig, collectionRoot, runtime, - collection + collection, + runSingleRequestByPathname ) { try { let request; let nextRequestName; + let shouldStopRunnerExecution = false; let item = { pathname: path.join(collectionPath, filename), ...bruJson @@ -68,11 +70,41 @@ const runSingleRequest = async function ( collectionPath, onConsoleLog, processEnvVars, - scriptingConfig + scriptingConfig, + runSingleRequestByPathname ); if (result?.nextRequestName !== undefined) { nextRequestName = result.nextRequestName; } + + if (result?.stopExecution) { + shouldStopRunnerExecution = true; + } + + if (result?.skipRequest) { + return { + test: { + filename: filename + }, + request: { + method: request.method, + url: request.url, + headers: request.headers, + data: request.data + }, + response: { + status: 'skipped', + statusText: 'request skipped via pre-request script', + data: null, + responseTime: 0 + }, + error: 'Request has been skipped from pre-request script', + skipped: true, + assertionResults: [], + testResults: [], + shouldStopRunnerExecution + }; + } } // interpolate variables inside request @@ -255,7 +287,7 @@ const runSingleRequest = async function ( let axiosInstance = makeAxiosInstance(); if (request.ntlmConfig) { - axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance) + axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) delete request.ntlmConfig; } @@ -323,7 +355,8 @@ const runSingleRequest = async function ( error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!', assertionResults: [], testResults: [], - nextRequestName: nextRequestName + nextRequestName: nextRequestName, + shouldStopRunnerExecution }; } } @@ -363,11 +396,16 @@ const runSingleRequest = async function ( collectionPath, null, processEnvVars, - scriptingConfig + scriptingConfig, + runSingleRequestByPathname ); if (result?.nextRequestName !== undefined) { nextRequestName = result.nextRequestName; } + + if (result?.stopExecution) { + shouldStopRunnerExecution = true; + } } // run assertions @@ -408,13 +446,18 @@ const runSingleRequest = async function ( collectionPath, null, processEnvVars, - scriptingConfig + scriptingConfig, + runSingleRequestByPathname ); testResults = get(result, 'results', []); if (result?.nextRequestName !== undefined) { nextRequestName = result.nextRequestName; } + + if (result?.stopExecution) { + shouldStopRunnerExecution = true; + } } if (testResults?.length) { @@ -447,7 +490,8 @@ const runSingleRequest = async function ( error: null, assertionResults, testResults, - nextRequestName: nextRequestName + nextRequestName: nextRequestName, + shouldStopRunnerExecution }; } catch (err) { console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`)); diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 365732c48..64e17cb39 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -204,5 +204,6 @@ module.exports = { mergeHeaders, mergeVars, mergeScripts, + findItemInCollection, getTreePathFromCollectionToItem } \ No newline at end of file diff --git a/packages/bruno-cli/tests/runner/prepare-request.spec.js b/packages/bruno-cli/tests/runner/prepare-request.spec.js index 37d3e34d3..ffc9986df 100644 --- a/packages/bruno-cli/tests/runner/prepare-request.spec.js +++ b/packages/bruno-cli/tests/runner/prepare-request.spec.js @@ -1,5 +1,4 @@ -const { describe, it, expect } = require('@jest/globals'); - +const { describe, it, expect, beforeEach } = require('@jest/globals'); const prepareRequest = require('../../src/runner/prepare-request'); describe('prepare-request: prepareRequest', () => { @@ -22,4 +21,144 @@ describe('prepare-request: prepareRequest', () => { expect(result.data).toEqual(expected); }); }); + + describe('Properly maps inherited auth from collectionRoot', () => { + // Initialize Test Fixtures + let collection, item; + + beforeEach(() => { + collection = { + name: 'Test Collection', + root: { + request: { + auth: {} + } + } + }; + + item = { + name: 'Test Request', + type: 'http-request', + request: { + method: 'GET', + headers: [], + params: [], + url: 'https://usebruno.com', + auth: { + mode: 'inherit' + }, + script: { + req: 'console.log("Pre Request")', + res: 'console.log("Post Response")' + } + } + }; + }); + + describe('API Key Authentication', () => { + it('If collection auth is apikey in header', () => { + collection.root.request.auth = { + mode: "apikey", + apikey: { + key: "x-api-key", + value: "{{apiKey}}", + placement: "header" + } + }; + + const result = prepareRequest(item, collection); + expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}'); + }); + + it('If collection auth is apikey in header and request has existing headers', () => { + collection.root.request.auth = { + mode: "apikey", + apikey: { + key: "x-api-key", + value: "{{apiKey}}", + placement: "header" + } + }; + + item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true }); + const result = prepareRequest(item, collection); + expect(result.headers).toHaveProperty('Content-Type', 'application/json'); + expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}'); + }); + + it('If collection auth is apikey in query parameters', () => { + collection.root.request.auth = { + mode: "apikey", + apikey: { + key: "x-api-key", + value: "{{apiKey}}", + placement: "queryparams" + } + }; + + const urlObj = new URL(item.request.url); + urlObj.searchParams.set(collection.root.request.auth.apikey.key, collection.root.request.auth.apikey.value); + + const expected = urlObj.toString(); + const result = prepareRequest(item, collection); + expect(result.url).toEqual(expected); + }); + }); + + describe('Basic Authentication', () => { + it('If collection auth is basic auth', () => { + collection.root.request.auth = { + mode: 'basic', + basic: { + username: 'testUser', + password: 'testPass123' + } + }; + + const result = prepareRequest(item, collection); + const expected = { username: 'testUser', password: 'testPass123' }; + expect(result.auth).toEqual(expected); + }); + }); + + describe('Bearer Token Authentication', () => { + it('If collection auth is bearer token', () => { + collection.root.request.auth = { + mode: 'bearer', + bearer: { + token: 'token' + } + }; + + const result = prepareRequest(item, collection); + expect(result.headers).toHaveProperty('Authorization', 'Bearer token'); + }); + + it('If collection auth is bearer token and request has existing headers', () => { + collection.root.request.auth = { + mode: 'bearer', + bearer: { + token: 'token' + } + }; + + item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true }); + + const result = prepareRequest(item, collection); + expect(result.headers).toHaveProperty('Authorization', 'Bearer token'); + expect(result.headers).toHaveProperty('Content-Type', 'application/json'); + }); + }); + + describe('No Authentication', () => { + it('If request does not have auth configured', () => { + delete item.request.auth; + let result; + expect(() => { + result = prepareRequest(item, collection); + }).not.toThrow(); + expect(result).toBeDefined(); + }); + }); + }); }); diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 16a032b3a..c3d2cc2e5 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -9,6 +9,7 @@ "scripts": { "clean": "rimraf dist", "dev": "electron .", + "debug": "electron . --inspect=9229", "dist:mac": "electron-builder --mac --config electron-builder-config.js", "dist:win": "electron-builder --win --config electron-builder-config.js", "dist:linux": "electron-builder --linux AppImage --config electron-builder-config.js", @@ -33,7 +34,7 @@ "@usebruno/vm2": "^3.9.13", "about-window": "^1.15.2", "aws4-axios": "^3.3.0", - "axios": "1.7.5", + "axios": "1.7.7", "axios-ntlm": "^1.4.2", "chai": "^4.3.7", "chokidar": "^3.5.3", diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index 5c9889e13..7bd74c43b 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const { dialog, ipcMain } = require('electron'); const Yup = require('yup'); -const { isDirectory, normalizeAndResolvePath } = require('../utils/filesystem'); +const { isDirectory, normalizeAndResolvePath, getCollectionStats } = require('../utils/filesystem'); const { generateUidBasedOnHash } = require('../utils/common'); // todo: bruno.json config schema validation errors must be propagated to the UI @@ -59,7 +59,7 @@ const openCollectionDialog = async (win, watcher) => { const openCollection = async (win, watcher, collectionPath, options = {}) => { if (!watcher.hasWatcher(collectionPath)) { try { - const brunoConfig = await getCollectionConfigFile(collectionPath); + let brunoConfig = await getCollectionConfigFile(collectionPath); const uid = generateUidBasedOnHash(collectionPath); if (!brunoConfig.ignore || brunoConfig.ignore.length === 0) { @@ -70,6 +70,10 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => { brunoConfig.ignore = ['node_modules', '.git']; } + const { size, filesCount } = await getCollectionStats(collectionPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', win, collectionPath, uid, brunoConfig); } catch (err) { diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 43d01153d..b2b60fd55 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -2,8 +2,8 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); const chokidar = require('chokidar'); -const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath } = require('../utils/filesystem'); -const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru'); +const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem'); +const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru'); const { dotenvToJson } = require('@usebruno/lang'); const { uuid } = require('../utils/common'); @@ -13,6 +13,9 @@ const { setDotEnvVars } = require('../store/process-env'); const { setBrunoConfig } = require('../store/bruno-config'); const EnvironmentSecretsStore = require('../store/env-secrets'); const UiStateSnapshot = require('../store/ui-state-snapshot'); +const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection'); + +const MAX_FILE_SIZE = 2.5 * 1024 * 1024; const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -44,28 +47,6 @@ const isCollectionRootBruFile = (pathname, collectionPath) => { return dirname === collectionPath && basename === 'collection.bru'; }; -const hydrateRequestWithUuid = (request, pathname) => { - request.uid = getRequestUid(pathname); - - const params = _.get(request, 'request.params', []); - const headers = _.get(request, 'request.headers', []); - const requestVars = _.get(request, 'request.vars.req', []); - const responseVars = _.get(request, 'request.vars.res', []); - const assertions = _.get(request, 'request.assertions', []); - const bodyFormUrlEncoded = _.get(request, 'request.body.formUrlEncoded', []); - const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []); - - params.forEach((param) => (param.uid = uuid())); - headers.forEach((header) => (header.uid = uuid())); - requestVars.forEach((variable) => (variable.uid = uuid())); - responseVars.forEach((variable) => (variable.uid = uuid())); - assertions.forEach((assertion) => (assertion.uid = uuid())); - bodyFormUrlEncoded.forEach((param) => (param.uid = uuid())); - bodyMultipartForm.forEach((param) => (param.uid = uuid())); - - return request; -}; - const hydrateBruCollectionFileWithUuid = (collectionRoot) => { const params = _.get(collectionRoot, 'request.params', []); const headers = _.get(collectionRoot, 'request.headers', []); @@ -99,7 +80,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) let bruContent = fs.readFileSync(pathname, 'utf8'); - file.data = bruToEnvJson(bruContent); + file.data = await bruToEnvJson(bruContent); file.data.name = basename.substring(0, basename.length - 4); file.data.uid = getRequestUid(pathname); @@ -134,7 +115,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat }; const bruContent = fs.readFileSync(pathname, 'utf8'); - file.data = bruToEnvJson(bruContent); + file.data = await bruToEnvJson(bruContent); file.data.name = basename.substring(0, basename.length - 4); file.data.uid = getRequestUid(pathname); _.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid())); @@ -179,7 +160,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => { } }; -const add = async (win, pathname, collectionUid, collectionPath) => { +const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread) => { console.log(`watcher add: ${pathname}`); if (isBrunoConfigFile(pathname, collectionPath)) { @@ -228,7 +209,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => { try { let bruContent = fs.readFileSync(pathname, 'utf8'); - file.data = collectionBruToJson(bruContent); + file.data = await collectionBruToJson(bruContent); hydrateBruCollectionFileWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'addFile', file); @@ -241,7 +222,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => { // Is this a folder.bru file? if (path.basename(pathname) === 'folder.bru') { - console.log('folder.bru file detected'); const file = { meta: { collectionUid, @@ -254,7 +234,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => { try { let bruContent = fs.readFileSync(pathname, 'utf8'); - file.data = collectionBruToJson(bruContent); + file.data = await collectionBruToJson(bruContent); hydrateBruCollectionFileWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'addFile', file); @@ -274,15 +254,67 @@ const add = async (win, pathname, collectionUid, collectionPath) => { } }; + const fileStats = fs.statSync(pathname); + let bruContent = fs.readFileSync(pathname, 'utf8'); + // If worker thread is not used, we can directly parse the file + if (!useWorkerThread) { + try { + file.data = await bruToJson(bruContent); + file.partial = false; + file.loading = false; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + } catch (error) { + console.error(error); + } + return; + } + try { - let bruContent = fs.readFileSync(pathname, 'utf8'); - - file.data = bruToJson(bruContent); + // we need to send a partial file info to the UI + // so that the UI can display the file in the collection tree + file.data = { + name: path.basename(pathname), + type: 'http-request' + }; + const metaJson = await bruToJson(parseBruFileMeta(bruContent), true); + file.data = metaJson; + file.partial = true; + file.loading = false; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + + if (fileStats.size < MAX_FILE_SIZE) { + // This is to update the loading indicator in the UI + file.data = metaJson; + file.partial = false; + file.loading = true; + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + + // This is to update the file info in the UI + file.data = await bruToJsonViaWorker(bruContent); + file.partial = false; + file.loading = false; + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + } + } catch(error) { + file.data = { + name: path.basename(pathname), + type: 'http-request' + }; + file.error = { + message: error?.message + }; + file.partial = true; + file.loading = false; + file.size = sizeInMB(fileStats?.size); hydrateRequestWithUuid(file.data, pathname); win.webContents.send('main:collection-tree-updated', 'addFile', file); - } catch (err) { - console.error(err); } } }; @@ -357,7 +389,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { try { let bruContent = fs.readFileSync(pathname, 'utf8'); - file.data = collectionBruToJson(bruContent); + file.data = await collectionBruToJson(bruContent); hydrateBruCollectionFileWithUuid(file.data); win.webContents.send('main:collection-tree-updated', 'change', file); return; @@ -378,7 +410,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { }; const bru = fs.readFileSync(pathname, 'utf8'); - file.data = bruToJson(bru); + file.data = await bruToJson(bru); hydrateRequestWithUuid(file.data, pathname); win.webContents.send('main:collection-tree-updated', 'change', file); @@ -424,10 +456,10 @@ const unlinkDir = (win, pathname, collectionUid, collectionPath) => { win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); }; -const onWatcherSetupComplete = (win, collectionPath) => { +const onWatcherSetupComplete = (win, watchPath) => { const UiStateSnapshotStore = new UiStateSnapshot(); const collectionsSnapshotState = UiStateSnapshotStore.getCollections(); - const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == collectionPath); + const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath); win.webContents.send('main:hydrate-app-with-ui-state-snapshot', collectionSnapshotState); }; @@ -436,7 +468,7 @@ class Watcher { this.watchers = {}; } - addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) { + addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); } @@ -467,7 +499,7 @@ class Watcher { let startedNewWatcher = false; watcher .on('ready', () => onWatcherSetupComplete(win, watchPath)) - .on('add', (pathname) => add(win, pathname, collectionUid, watchPath)) + .on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread)) .on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath)) .on('change', (pathname) => change(win, pathname, collectionUid, watchPath)) .on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath)) @@ -488,7 +520,7 @@ class Watcher { 'Update you system config to allow more concurrently watched files with:', '"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"' ); - this.addWatcher(win, watchPath, collectionUid, brunoConfig, true); + this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, useWorkerThread); } else { console.error(`An error occurred in the watcher for: ${watchPath}`, error); } diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 7fe43218a..a641a95a7 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -7,10 +7,13 @@ const { collectionBruToJson: _collectionBruToJson, jsonToCollectionBru: _jsonToCollectionBru } = require('@usebruno/lang'); +const BruParserWorker = require('./workers'); -const collectionBruToJson = (bru) => { +const bruParserWorker = new BruParserWorker(); + +const collectionBruToJson = async (data, parsed = false) => { try { - const json = _collectionBruToJson(bru); + const json = parsed ? data : _collectionBruToJson(data); const transformedJson = { request: { @@ -38,7 +41,7 @@ const collectionBruToJson = (bru) => { } }; -const jsonToCollectionBru = (json, isFolder) => { +const jsonToCollectionBru = async (json, isFolder) => { try { const collectionBruJson = { headers: _.get(json, 'request.headers', []), @@ -73,7 +76,7 @@ const jsonToCollectionBru = (json, isFolder) => { } }; -const bruToEnvJson = (bru) => { +const bruToEnvJson = async (bru) => { try { const json = bruToEnvJsonV2(bru); @@ -90,7 +93,7 @@ const bruToEnvJson = (bru) => { } }; -const envJsonToBru = (json) => { +const envJsonToBru = async (json) => { try { const bru = envJsonToBruV2(json); return bru; @@ -105,12 +108,12 @@ const envJsonToBru = (json) => { * We map the json response from the bru lang and transform it into the DSL * format that the app uses * - * @param {string} bru The BRU file content. + * @param {string} data The BRU file content. * @returns {object} The JSON representation of the BRU file. */ -const bruToJson = (bru) => { +const bruToJson = (data, parsed = false) => { try { - const json = bruToJsonV2(bru); + const json = parsed ? data : bruToJsonV2(data); let requestType = _.get(json, 'meta.type'); if (requestType === 'http') { @@ -149,6 +152,16 @@ const bruToJson = (bru) => { return Promise.reject(e); } }; + +const bruToJsonViaWorker = async (data) => { + try { + const json = await bruParserWorker?.bruToJson(data); + return bruToJson(json, true); + } catch (e) { + return Promise.reject(e); + } +}; + /** * The transformer function for converting a JSON to BRU file. * @@ -158,7 +171,7 @@ const bruToJson = (bru) => { * @param {object} json The JSON representation of the BRU file. * @returns {string} The BRU file content. */ -const jsonToBru = (json) => { +const jsonToBru = async (json) => { let type = _.get(json, 'type'); if (type === 'http-request') { type = 'http'; @@ -195,14 +208,59 @@ const jsonToBru = (json) => { docs: _.get(json, 'request.docs', '') }; - return jsonToBruV2(bruJson); + const bru = jsonToBruV2(bruJson); + return bru; }; +const jsonToBruViaWorker = async (json) => { + let type = _.get(json, 'type'); + if (type === 'http-request') { + type = 'http'; + } else if (type === 'graphql-request') { + type = 'graphql'; + } else { + type = 'http'; + } + + const sequence = _.get(json, 'seq'); + const bruJson = { + meta: { + name: _.get(json, 'name'), + type: type, + seq: !isNaN(sequence) ? Number(sequence) : 1 + }, + http: { + method: _.lowerCase(_.get(json, 'request.method')), + url: _.get(json, 'request.url'), + auth: _.get(json, 'request.auth.mode', 'none'), + body: _.get(json, 'request.body.mode', 'none') + }, + params: _.get(json, 'request.params', []), + headers: _.get(json, 'request.headers', []), + auth: _.get(json, 'request.auth', {}), + body: _.get(json, 'request.body', {}), + script: _.get(json, 'request.script', {}), + vars: { + req: _.get(json, 'request.vars.req', []), + res: _.get(json, 'request.vars.res', []) + }, + assertions: _.get(json, 'request.assertions', []), + tests: _.get(json, 'request.tests', ''), + docs: _.get(json, 'request.docs', '') + }; + + const bru = await bruParserWorker?.jsonToBru(bruJson) + return bru; +}; + + module.exports = { bruToJson, + bruToJsonViaWorker, jsonToBru, bruToEnvJson, envJsonToBru, collectionBruToJson, - jsonToCollectionBru + jsonToCollectionBru, + jsonToBruViaWorker }; diff --git a/packages/bruno-electron/src/bru/workers/index.js b/packages/bruno-electron/src/bru/workers/index.js new file mode 100644 index 000000000..62c19f99d --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/index.js @@ -0,0 +1,57 @@ +const WorkerQueue = require("../../workers"); +const path = require("path"); + +const getSize = (data) => { + return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8'); +} + +/** + * Lanes are used to determine which worker queue to use based on the size of the data. + * + * The first lane is for smaller files (<0.1MB), the second lane is for larger files (>=0.1MB). + * This helps with parsing performance. + */ +const LANES = [{ + maxSize: 0.1 +},{ + maxSize: 100 +}]; + +class BruParserWorker { + constructor() { + this.workerQueues = LANES?.map(lane => ({ + maxSize: lane?.maxSize, + workerQueue: new WorkerQueue() + })); + } + + getWorkerQueue(size) { + // Find the first queue that can handle the given size + // or fallback to the last queue for largest files + const queueForSize = this.workerQueues.find((queue) => + queue.maxSize >= size + ); + + return queueForSize?.workerQueue ?? this.workerQueues.at(-1).workerQueue; + } + + async enqueueTask({data, scriptFile }) { + const size = getSize(data); + const workerQueue = this.getWorkerQueue(size); + return workerQueue.enqueue({ + data, + priority: size, + scriptPath: path.join(__dirname, `./scripts/${scriptFile}.js`) + }); + } + + async bruToJson(data) { + return this.enqueueTask({ data, scriptFile: `bru-to-json` }); + } + + async jsonToBru(data) { + return this.enqueueTask({ data, scriptFile: `json-to-bru` }); + } +} + +module.exports = BruParserWorker; \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js new file mode 100644 index 000000000..c1bbb44e7 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js @@ -0,0 +1,14 @@ +const { workerData, parentPort } = require('worker_threads'); +const { + bruToJsonV2, +} = require('@usebruno/lang'); + +try { + const bru = workerData; + const json = bruToJsonV2(bru); + parentPort.postMessage(json); +} +catch(error) { + console.error(error); + parentPort.postMessage({ error: error?.message }); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js b/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js new file mode 100644 index 000000000..e08be60b9 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js @@ -0,0 +1,13 @@ +const { workerData, parentPort } = require('worker_threads'); +const { + jsonToBruV2, +} = require('@usebruno/lang'); +try { + const json = workerData; + const bru = jsonToBruV2(json); + parentPort.postMessage(bru); +} +catch(error) { + console.error(error); + parentPort.postMessage({ error: error?.message }); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 898324892..c7454c113 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -4,7 +4,7 @@ const fsExtra = require('fs-extra'); const os = require('os'); const path = require('path'); const { ipcMain, shell, dialog, app } = require('electron'); -const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru'); +const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru'); const { isValidPathname, @@ -24,6 +24,8 @@ const { isWindowsOS, isValidFilename, hasSubDirectories, + getCollectionStats, + sizeInMB } = require('../utils/filesystem'); const { openCollectionDialog } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); @@ -32,11 +34,17 @@ const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cook const EnvironmentSecretsStore = require('../store/env-secrets'); const CollectionSecurityStore = require('../store/collection-security'); const UiStateSnapshotStore = require('../store/ui-state-snapshot'); +const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection'); const environmentSecretsStore = new EnvironmentSecretsStore(); const collectionSecurityStore = new CollectionSecurityStore(); const uiStateSnapshotStore = new UiStateSnapshotStore(); +// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not. +const MAX_COLLECTION_SIZE_IN_MB = 5; +const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 2; +const MAX_COLLECTION_FILES_COUNT = 100; + const envHasSecrets = (environment = {}) => { const secrets = _.filter(environment.variables, (v) => v.secret); @@ -97,6 +105,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const content = await stringifyJson(brunoConfig); await writeFile(path.join(dirPath, 'bruno.json'), content); + const { size, filesCount } = await getCollectionStats(dirPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid, brunoConfig); } catch (error) { @@ -126,9 +138,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const brunoJsonFilePath = path.join(previousPath, 'bruno.json'); const content = fs.readFileSync(brunoJsonFilePath, 'utf8'); - //Change new name of collection - let json = JSON.parse(content); - json.name = collectionName; + // Change new name of collection + let brunoConfig = JSON.parse(content); + brunoConfig.name = collectionName; const cont = await stringifyJson(json); // write the bruno.json to new dir @@ -147,7 +159,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection fs.copyFileSync(sourceFilePath, newFilePath); } - mainWindow.webContents.send('main:collection-opened', dirPath, uid, json); + const { size, filesCount } = await getCollectionStats(dirPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + + mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid); } ); @@ -184,7 +200,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection name: folderName }; - const content = jsonToCollectionBru( + const content = await jsonToCollectionBru( folderRoot, true // isFolder ); @@ -197,7 +213,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection try { const collectionBruFilePath = path.join(collectionPathname, 'collection.bru'); - const content = jsonToCollectionBru(collectionRoot); + const content = await jsonToCollectionBru(collectionRoot); await writeFile(collectionBruFilePath, content); } catch (error) { return Promise.reject(error); @@ -213,7 +229,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (!isValidFilename(request.name)) { throw new Error(`path: ${request.name}.bru is not a valid filename`); } - const content = jsonToBru(request); + const content = await jsonToBruViaWorker(request); await writeFile(pathname, content); } catch (error) { return Promise.reject(error); @@ -227,7 +243,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`path: ${pathname} does not exist`); } - const content = jsonToBru(request); + const content = await jsonToBruViaWorker(request); await writeFile(pathname, content); } catch (error) { return Promise.reject(error); @@ -245,7 +261,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`path: ${pathname} does not exist`); } - const content = jsonToBru(request); + const content = await jsonToBruViaWorker(request); await writeFile(pathname, content); } } catch (error) { @@ -275,7 +291,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection environmentSecretsStore.storeEnvSecrets(collectionPathname, environment); } - const content = envJsonToBru(environment); + const content = await envJsonToBru(environment); await writeFile(envFilePath, content); } catch (error) { @@ -300,7 +316,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection environmentSecretsStore.storeEnvSecrets(collectionPathname, environment); } - const content = envJsonToBru(environment); + const content = await envJsonToBru(environment); await writeFile(envFilePath, content); } catch (error) { return Promise.reject(error); @@ -412,11 +428,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // update name in file and save new copy, then delete old copy const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read - const jsonData = bruToJson(data); + const jsonData = await bruToJsonViaWorker(data); jsonData.name = newName; moveRequestUid(oldPath, newPath); - const content = jsonToBru(jsonData); + const content = await jsonToBruViaWorker(jsonData); await fs.promises.unlink(oldPath); await writeFile(newPath, content); @@ -516,9 +532,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // Recursive function to parse the collection items and create files/folders const parseCollectionItems = (items = [], currentPath) => { - items.forEach((item) => { + items.forEach(async (item) => { if (['http-request', 'graphql-request'].includes(item.type)) { - const content = jsonToBru(item); + const content = await jsonToBruViaWorker(item); const filePath = path.join(currentPath, `${item.name}.bru`); fs.writeFileSync(filePath, content); } @@ -529,7 +545,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (item?.root?.meta?.name) { const folderBruFilePath = path.join(folderPath, 'folder.bru'); - const folderContent = jsonToCollectionBru( + const folderContent = await jsonToCollectionBru( item.root, true // isFolder ); @@ -554,8 +570,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection fs.mkdirSync(envDirPath); } - environments.forEach((env) => { - const content = envJsonToBru(env); + environments.forEach(async (env) => { + const content = await envJsonToBru(env); const filePath = path.join(envDirPath, `${env.name}.bru`); fs.writeFileSync(filePath, content); }); @@ -579,15 +595,19 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection await createDirectory(collectionPath); const uid = generateUidBasedOnHash(collectionPath); - const brunoConfig = getBrunoJsonConfig(collection); + let brunoConfig = getBrunoJsonConfig(collection); const stringifiedBrunoConfig = await stringifyJson(brunoConfig); // Write the Bruno configuration to a file await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig); - const collectionContent = jsonToCollectionBru(collection.root); + const collectionContent = await jsonToCollectionBru(collection.root); await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent); + const { size, filesCount } = await getCollectionStats(collectionPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); @@ -609,9 +629,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // Recursive function to parse the folder and create files/folders const parseCollectionItems = (items = [], currentPath) => { - items.forEach((item) => { + items.forEach(async (item) => { if (['http-request', 'graphql-request'].includes(item.type)) { - const content = jsonToBru(item); + const content = await jsonToBruViaWorker(item); const filePath = path.join(currentPath, `${item.name}.bru`); fs.writeFileSync(filePath, content); } @@ -621,7 +641,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // If folder has a root element, then I should write its folder.bru file if (item.root) { - const folderContent = jsonToCollectionBru(item.root, true); + const folderContent = await jsonToCollectionBru(item.root, true); if (folderContent) { const bruFolderPath = path.join(folderPath, `folder.bru`); fs.writeFileSync(bruFolderPath, folderContent); @@ -639,7 +659,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection // If initial folder has a root element, then I should write its folder.bru file if (itemFolder.root) { - const folderContent = jsonToCollectionBru(itemFolder.root, true); + const folderContent = await jsonToCollectionBru(itemFolder.root, true); if (folderContent) { const bruFolderPath = path.join(collectionPath, `folder.bru`); fs.writeFileSync(bruFolderPath, folderContent); @@ -655,13 +675,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => { try { - for (let item of itemsToResequence) { + for await (let item of itemsToResequence) { const bru = fs.readFileSync(item.pathname, 'utf8'); - const jsonData = bruToJson(bru); + const jsonData = await bruToJsonViaWorker(bru); if (jsonData.seq !== item.seq) { jsonData.seq = item.seq; - const content = jsonToBru(jsonData); + const content = await jsonToBruViaWorker(jsonData); await writeFile(item.pathname, content); } } @@ -776,6 +796,131 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(error.message); } }); + + ipcMain.handle('renderer:load-request-via-worker', async (event, { collectionUid, pathname }) => { + let fileStats; + try { + fileStats = fs.statSync(pathname); + if (hasBruExtension(pathname)) { + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname) + } + }; + let bruContent = fs.readFileSync(pathname, 'utf8'); + const metaJson = await bruToJson(parseBruFileMeta(bruContent), true); + file.data = metaJson; + file.loading = true; + file.partial = true; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + file.data = await bruToJsonViaWorker(bruContent); + file.partial = false; + file.loading = true; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + } + } catch (error) { + if (hasBruExtension(pathname)) { + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname) + } + }; + let bruContent = fs.readFileSync(pathname, 'utf8'); + const metaJson = await bruToJson(parseBruFileMeta(bruContent), true); + file.data = metaJson; + file.partial = true; + file.loading = false; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + } + return Promise.reject(error); + } + }); + + ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => { + let fileStats; + try { + fileStats = fs.statSync(pathname); + if (hasBruExtension(pathname)) { + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname) + } + }; + let bruContent = fs.readFileSync(pathname, 'utf8'); + const metaJson = await bruToJson(parseBruFileMeta(bruContent), true); + file.data = metaJson; + file.loading = true; + file.partial = true; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + file.data = bruToJson(bruContent); + file.partial = false; + file.loading = true; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + } + } catch (error) { + if (hasBruExtension(pathname)) { + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname) + } + }; + let bruContent = fs.readFileSync(pathname, 'utf8'); + const metaJson = await bruToJson(parseBruFileMeta(bruContent), true); + file.data = metaJson; + file.partial = true; + file.loading = false; + file.size = sizeInMB(fileStats?.size); + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + } + return Promise.reject(error); + } + }); + + ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => { + const { + size, + filesCount, + maxFileSize + } = await getCollectionStats(collectionPathname); + + const shouldLoadCollectionAsync = + (size > MAX_COLLECTION_SIZE_IN_MB) || + (filesCount > MAX_COLLECTION_FILES_COUNT) || + (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB); + + watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync); + }); + + ipcMain.handle('renderer:show-in-folder', async (event, filePath) => { + try { + if (!filePath) { + throw new Error('File path is required'); + } + shell.showItemInFolder(filePath); + } catch (error) { + console.error('Error in show-in-folder: ', error); + throw error; + } + }); }; const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { @@ -790,8 +935,7 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) = shell.openExternal(docsURL); }); - ipcMain.on('main:collection-opened', (win, pathname, uid, brunoConfig) => { - watcher.addWatcher(win, pathname, uid, brunoConfig); + ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => { lastOpenedCollections.add(pathname); app.addRecentDocument(pathname); }); diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 1865426e0..4a2b29c7d 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -274,11 +274,10 @@ const configureRequest = async ( }); } - let axiosInstance = makeAxiosInstance(); if (request.ntlmConfig) { - axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance) + axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults) delete request.ntlmConfig; } @@ -403,7 +402,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -437,6 +436,8 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: scriptResult.globalEnvironmentVariables }); + + collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables; } // interpolate variables inside request @@ -470,7 +471,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -507,6 +508,8 @@ const registerNetworkIpc = (mainWindow) => { if (result?.error) { mainWindow.webContents.send('main:display-error', result.error); } + + collection.globalEnvironmentVariables = result.globalEnvironmentVariables; } // run post-response script @@ -537,11 +540,13 @@ const registerNetworkIpc = (mainWindow) => { mainWindow.webContents.send('main:global-environment-variables-update', { globalEnvironmentVariables: scriptResult.globalEnvironmentVariables }); + + collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables; } return scriptResult; }; - const runRequest = async ({ item, collection, environment, runtimeVariables, runInBackground = false }) => { + const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => { const collectionUid = collection.uid; const collectionPath = collection.pathname; const cancelTokenUid = uuid(); @@ -553,9 +558,9 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !itemPathname?.endsWith('.bru')) { itemPathname = `${itemPathname}.bru`; } - const _item = findItemInCollectionByPathname(collection, itemPathname); + const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if(_item) { - const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true }); + const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); @@ -570,11 +575,8 @@ const registerNetworkIpc = (mainWindow) => { cancelTokenUid }); - const collectionRoot = get(collection, 'root', {}); const request = prepareRequest(item, collection); request.__bruno__executionMode = 'standalone'; - const envVars = getEnvVars(environment); - const processEnvVars = getProcessEnvVars(collectionUid); const brunoConfig = getBrunoConfig(collectionUid); const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); @@ -589,7 +591,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -674,7 +676,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -758,7 +760,10 @@ const registerNetworkIpc = (mainWindow) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => { - return await runRequest({ item, collection, environment, runtimeVariables }); + const collectionUid = collection.uid; + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(collectionUid); + return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false }); }); ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, runtimeVariables) => { @@ -782,7 +787,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -818,7 +823,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -888,7 +893,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -912,7 +917,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -949,7 +954,8 @@ const registerNetworkIpc = (mainWindow) => { const brunoConfig = getBrunoConfig(collectionUid); const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); - const collectionRoot = get(collection, 'root', {}); + const envVars = getEnvVars(environment); + const processEnvVars = getProcessEnvVars(collectionUid); let stopRunnerExecution = false; const abortController = new AbortController(); @@ -961,9 +967,9 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !itemPathname?.endsWith('.bru')) { itemPathname = `${itemPathname}.bru`; } - const _item = findItemInCollectionByPathname(collection, itemPathname); + const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if(_item) { - const res = await runRequest({ item: _item, collection, environment, runtimeVariables, runInBackground: true }); + const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true }); resolve(res); } reject(`bru.runRequest: invalid request path - ${itemPathname}`); @@ -983,7 +989,6 @@ const registerNetworkIpc = (mainWindow) => { }); try { - const envVars = getEnvVars(environment); let folderRequests = []; if (recursive) { @@ -1035,7 +1040,6 @@ const registerNetworkIpc = (mainWindow) => { request.__bruno__executionMode = 'runner'; const requestUid = uuid(); - const processEnvVars = getProcessEnvVars(collectionUid); try { const preRequestScriptResult = await runPreRequest( @@ -1043,7 +1047,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, @@ -1181,7 +1185,7 @@ const registerNetworkIpc = (mainWindow) => { requestUid, envVars, collectionPath, - collectionRoot, + collection, collectionUid, runtimeVariables, processEnvVars, diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 15d5574e2..96e75acae 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -1,3 +1,7 @@ +const fs = require('fs'); +const { getRequestUid } = require('../cache/requestUids'); +const { uuid } = require('./common'); + const { get, each, find, compact } = require('lodash'); const os = require('os'); @@ -7,7 +11,7 @@ const mergeHeaders = (collection, request, requestTreePath) => { let collectionHeaders = get(collection, 'root.request.headers', []); collectionHeaders.forEach((header) => { if (header.enabled) { - headers.set(header.name, header.value); + headers.set(header.name?.toLowerCase?.(), header.value); if (header?.name?.toLowerCase() === 'content-type') { contentTypeDefined = true; } @@ -19,14 +23,14 @@ const mergeHeaders = (collection, request, requestTreePath) => { let _headers = get(i, 'root.request.headers', []); _headers.forEach((header) => { if (header.enabled) { - headers.set(header.name, header.value); + headers.set(header.name?.toLowerCase?.(), header.value); } }); } else { const _headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []); _headers.forEach((header) => { if (header.enabled) { - headers.set(header.name, header.value); + headers.set(header.name?.toLowerCase?.(), header.value); } }); } @@ -203,6 +207,51 @@ const getTreePathFromCollectionToItem = (collection, _item) => { return path; }; +const parseBruFileMeta = (data) => { + try { + const metaRegex = /meta\s*{\s*([\s\S]*?)\s*}/; + const match = data?.match?.(metaRegex); + if (match) { + const metaContent = match[1].trim(); + const lines = metaContent.replace(/\r\n/g, '\n').split('\n'); + const metaJson = {}; + lines.forEach(line => { + const [key, value] = line.split(':').map(str => str.trim()); + if (key && value) { + metaJson[key] = isNaN(value) ? value : Number(value); + } + }); + return { meta: metaJson }; + } else { + console.log('No "meta" block found in the file.'); + } + } catch (err) { + console.error('Error reading file:', err); + } +} + +const hydrateRequestWithUuid = (request, pathname) => { + request.uid = getRequestUid(pathname); + + const params = get(request, 'request.params', []); + const headers = get(request, 'request.headers', []); + const requestVars = get(request, 'request.vars.req', []); + const responseVars = get(request, 'request.vars.res', []); + const assertions = get(request, 'request.assertions', []); + const bodyFormUrlEncoded = get(request, 'request.body.formUrlEncoded', []); + const bodyMultipartForm = get(request, 'request.body.multipartForm', []); + + params.forEach((param) => (param.uid = uuid())); + headers.forEach((header) => (header.uid = uuid())); + requestVars.forEach((variable) => (variable.uid = uuid())); + responseVars.forEach((variable) => (variable.uid = uuid())); + assertions.forEach((assertion) => (assertion.uid = uuid())); + bodyFormUrlEncoded.forEach((param) => (param.uid = uuid())); + bodyMultipartForm.forEach((param) => (param.uid = uuid())); + + return request; +}; + const slash = (path) => { const isExtendedLengthPath = /^\\\\\?\\/.test(path); if (isExtendedLengthPath) { @@ -221,13 +270,18 @@ const findItemInCollectionByPathname = (collection, pathname) => { return findItemByPathname(flattenedItems, pathname); }; - module.exports = { mergeHeaders, mergeVars, mergeScripts, getTreePathFromCollectionToItem, + flattenItems, + findItem, + findItemInCollection, slash, findItemByPathname, - findItemInCollectionByPathname -} \ No newline at end of file + findItemInCollectionByPathname, + findParentItemInCollection, + parseBruFileMeta, + hydrateRequestWithUuid +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index d2f74d10e..0ab6bbf0a 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -211,6 +211,50 @@ const safeToRename = (oldPath, newPath) => { } }; +const getCollectionStats = async (directoryPath) => { + let size = 0; + let filesCount = 0; + let maxFileSize = 0; + + async function calculateStats(directory) { + const entries = await fsPromises.readdir(directory, { withFileTypes: true }); + + const tasks = entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + if (['node_modules', '.git'].includes(entry.name)) { + return; + } + + await calculateStats(fullPath); + } + + if (path.extname(fullPath) === '.bru') { + const stats = await fsPromises.stat(fullPath); + size += stats?.size; + if (maxFileSize < stats?.size) { + maxFileSize = stats?.size; + } + filesCount += 1; + } + }); + + await Promise.all(tasks); + } + + await calculateStats(directoryPath); + + size = sizeInMB(size); + maxFileSize = sizeInMB(maxFileSize); + + return { size, filesCount, maxFileSize }; +} + +const sizeInMB = (size) => { + return size / (1024 * 1024); +} + module.exports = { isValidPathname, exists, @@ -235,5 +279,7 @@ module.exports = { isWindowsOS, safeToRename, isValidFilename, - hasSubDirectories + hasSubDirectories, + getCollectionStats, + sizeInMB }; diff --git a/packages/bruno-electron/src/workers/index.js b/packages/bruno-electron/src/workers/index.js new file mode 100644 index 000000000..04836e9fc --- /dev/null +++ b/packages/bruno-electron/src/workers/index.js @@ -0,0 +1,60 @@ +const { Worker } = require('worker_threads'); + +class WorkerQueue { + constructor() { + this.queue = []; + this.isProcessing = false; + } + + async enqueue(task) { + const { priority, scriptPath, data } = task; + + return new Promise((resolve, reject) => { + this.queue.push({ priority, scriptPath, data, resolve, reject }); + this.queue?.sort((taskX, taskY) => taskX?.priority - taskY?.priority); + this.processQueue(); + }); + } + + async processQueue() { + if (this.isProcessing || this.queue.length === 0){ + return; + } + + this.isProcessing = true; + const { scriptPath, data, resolve, reject } = this.queue.shift(); + + try { + const result = await this.runWorker({ scriptPath, data }); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.isProcessing = false; + this.processQueue(); + } + } + + async runWorker({ scriptPath, data }) { + return new Promise((resolve, reject) => { + const worker = new Worker(scriptPath, { workerData: data }); + worker.on('message', (data) => { + if (data?.error) { + reject(new Error(data?.error)); + } + resolve(data); + worker.terminate(); + }); + worker.on('error', (error) => { + reject(error); + worker.terminate(); + }); + worker.on('exit', (code) => { + reject(new Error(`stopped with ${code} exit code`)); + worker.terminate(); + }); + }); + } +} + +module.exports = WorkerQueue; diff --git a/packages/bruno-electron/tests/utils/collection.spec.js b/packages/bruno-electron/tests/utils/collection.spec.js new file mode 100644 index 000000000..4efc9c002 --- /dev/null +++ b/packages/bruno-electron/tests/utils/collection.spec.js @@ -0,0 +1,121 @@ +const { parseBruFileMeta } = require("../../src/utils/collection"); + +describe('parseBruFileMeta', () => { + test('parses valid meta block correctly', () => { + const data = `meta { + name: 0.2_mb + type: http + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + name: '0.2_mb', + type: 'http', + seq: 1, + }, + }); + }); + + test('returns undefined for missing meta block', () => { + const data = `someOtherBlock { + key: value + }`; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles empty meta block gracefully', () => { + const data = `meta {}`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ meta: {} }); + }); + + test('ignores invalid lines in meta block', () => { + const data = `meta { + name: 0.2_mb + invalidLine + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + name: '0.2_mb', + seq: 1, + }, + }); + }); + + test('handles unexpected input gracefully', () => { + const data = null; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles missing colon gracefully', () => { + const data = `meta { + name 0.2_mb + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + seq: 1, + }, + }); + }); + + test('parses numeric values correctly', () => { + const data = `meta { + numValue: 1234 + floatValue: 12.34 + strValue: some_text + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + numValue: 1234, + floatValue: 12.34, + strValue: 'some_text', + }, + }); + }); + + test('handles syntax error in meta block 1', () => { + const data = `meta + name: 0.2_mb + type: http + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles syntax error in meta block 2', () => { + const data = `meta { + name: 0.2_mb + type: http + seq: 1 + `; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index 8ad1d8e46..ad400ab58 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -21,7 +21,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "atob": "^2.1.2", - "axios": "1.7.5", + "axios": "1.7.7", "btoa": "^1.2.1", "chai": "^4.3.7", "chai-string": "^1.5.0", diff --git a/packages/bruno-js/src/bruno-response.js b/packages/bruno-js/src/bruno-response.js index 9e68045d9..faa315235 100644 --- a/packages/bruno-js/src/bruno-response.js +++ b/packages/bruno-js/src/bruno-response.js @@ -1,3 +1,5 @@ +const { get } = require('@usebruno/query'); + class BrunoResponse { constructor(res) { this.res = res; @@ -6,6 +8,13 @@ class BrunoResponse { this.headers = res ? res.headers : null; this.body = res ? res.data : null; this.responseTime = res ? res.responseTime : null; + + // Make the instance callable + const callable = (...args) => get(this.body, ...args); + Object.setPrototypeOf(callable, this.constructor.prototype); + Object.assign(callable, this); + + return callable; } getStatus() { diff --git a/packages/bruno-tests/collection/ping-another-one.bru b/packages/bruno-tests/collection/ping-another-one.bru new file mode 100644 index 000000000..84c1412a8 --- /dev/null +++ b/packages/bruno-tests/collection/ping-another-one.bru @@ -0,0 +1,15 @@ +meta { + name: ping-another-one + type: http + seq: 2 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +script:pre-request { + throw new Error('this should not execute in a collection run'); +} diff --git a/packages/bruno-tests/collection/ping.bru b/packages/bruno-tests/collection/ping.bru index 3abc7a2d4..8f4f3c6f7 100644 --- a/packages/bruno-tests/collection/ping.bru +++ b/packages/bruno-tests/collection/ping.bru @@ -9,3 +9,7 @@ get { body: none auth: none } + +script:pre-request { + bru.runner.stopExecution(); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runRequest-1.bru b/packages/bruno-tests/collection/scripting/api/bru/runRequest-1.bru new file mode 100644 index 000000000..95b87239f --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runRequest-1.bru @@ -0,0 +1,60 @@ +meta { + name: runRequest-1 + type: http + seq: 10 +} + +post { + url: {{echo-host}} + body: text + auth: none +} + +body:text { + bruno +} + +script:pre-request { + // reset values + bru.setVar('run-request-runtime-var', null); + bru.setEnvVar('run-request-env-var', null); + bru.setGlobalEnvVar('run-request-global-env-var', null); + + // the above vars will be set in the below request + const resp = await bru.runRequest('scripting/api/bru/runRequest-2'); + + bru.setVar('run-request-resp', { + data: resp?.data, + statusText: resp?.statusText, + status: resp?.status + }); +} + +tests { + test("should get runtime var set in runRequest-2", function() { + const val = bru.getVar("run-request-runtime-var"); + expect(val).to.equal("run-request-runtime-var-value"); + }); + + test("should get env var set in runRequest-2", function() { + const val = bru.getEnvVar("run-request-env-var"); + expect(val).to.equal("run-request-env-var-value"); + }); + + test("should get global env var set in runRequest-2", function() { + const val = bru.getGlobalEnvVar("run-request-global-env-var"); + const executionMode = req.getExecutionMode(); + if (executionMode == 'runner') { + expect(val).to.equal("run-request-global-env-var-value"); + } + }); + + test("should get response of runRequest-2", function() { + const val = bru.getVar('run-request-resp'); + expect(JSON.stringify(val)).to.equal(JSON.stringify({ + "data": "bruno", + "statusText": "OK", + "status": 200 + })); + }); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runRequest-2.bru b/packages/bruno-tests/collection/scripting/api/bru/runRequest-2.bru new file mode 100644 index 000000000..7a5f4d08d --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runRequest-2.bru @@ -0,0 +1,21 @@ +meta { + name: runRequest-2 + type: http + seq: 11 +} + +post { + url: {{echo-host}} + body: text + auth: none +} + +body:text { + bruno +} + +script:pre-request { + bru.setVar('run-request-runtime-var', 'run-request-runtime-var-value'); + bru.setEnvVar('run-request-env-var', 'run-request-env-var-value'); + bru.setGlobalEnvVar('run-request-global-env-var', 'run-request-global-env-var-value'); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runRequest.bru b/packages/bruno-tests/collection/scripting/api/bru/runRequest.bru new file mode 100644 index 000000000..7eb0e332c --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runRequest.bru @@ -0,0 +1,96 @@ +meta { + name: runRequest + type: http + seq: 2 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +headers { + foo: bar +} + +auth:basic { + username: asd + password: j +} + +auth:bearer { + token: +} + +body:json { + { + "hello": "bruno" + } +} + +assert { + res.status: eq 200 +} + +script:pre-request { + bru.setVar("runRequest-ping-res-1", null); + bru.setVar("runRequest-ping-res-2", null); + bru.setVar("runRequest-ping-res-3", null); + + let pingRes = await bru.runRequest('ping'); + bru.setVar('runRequest-ping-res-1', { + data: pingRes?.data, + statusText: pingRes?.statusText, + status: pingRes?.status + }); +} + +script:post-response { + let pingRes = await bru.runRequest('ping'); + bru.setVar('runRequest-ping-res-2', { + data: pingRes?.data, + statusText: pingRes?.statusText, + status: pingRes?.status + }); +} + +tests { + const pingRes = await bru.runRequest('ping'); + bru.setVar('runRequest-ping-res-3', { + data: pingRes?.data, + statusText: pingRes?.statusText, + status: pingRes?.status + }); + + test("should run request and return valid response in pre-request script", function() { + const expectedPingRes = { + data: "pong", + statusText: "OK", + status: 200 + }; + const pingRes = bru.getVar('runRequest-ping-res-1'); + expect(pingRes).to.eql(expectedPingRes); + }); + + test("should run request and return valid response in post-response script", function() { + const expectedPingRes = { + data: "pong", + statusText: "OK", + status: 200 + }; + const pingRes = bru.getVar('runRequest-ping-res-2'); + expect(pingRes).to.eql(expectedPingRes); + }); + + test("should run request and return valid response in tests script", function() { + const expectedPingRes = { + data: "pong", + statusText: "OK", + status: 200 + }; + const pingRes = bru.getVar('runRequest-ping-res-3'); + expect(pingRes).to.eql(expectedPingRes); + }); + +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runner/1.bru b/packages/bruno-tests/collection/scripting/api/bru/runner/1.bru new file mode 100644 index 000000000..97a7edbb6 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runner/1.bru @@ -0,0 +1,19 @@ +meta { + name: 1 + type: http + seq: 1 +} + +post { + url: https://echo.usebruno.com + body: none + auth: none +} + +script:pre-request { + bru.setVar('bru-runner-req', 1); +} + +script:post-response { + bru.setVar('bru.runner.skipRequest', true); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runner/2.bru b/packages/bruno-tests/collection/scripting/api/bru/runner/2.bru new file mode 100644 index 000000000..b1be74b22 --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runner/2.bru @@ -0,0 +1,19 @@ +meta { + name: 2 + type: http + seq: 2 +} + +post { + url: https://echo.usebruno.com + body: none + auth: none +} + +script:pre-request { + bru.runner.skipRequest(); +} + +script:post-response { + bru.setVar('bru.runner.skipRequest', false); +} diff --git a/packages/bruno-tests/collection/scripting/api/bru/runner/3.bru b/packages/bruno-tests/collection/scripting/api/bru/runner/3.bru new file mode 100644 index 000000000..4abe00b4c --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/runner/3.bru @@ -0,0 +1,11 @@ +meta { + name: 3 + type: http + seq: 3 +} + +post { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/packages/bruno-tests/collection_oauth2/bruno.json b/packages/bruno-tests/collection_oauth2/bruno.json index 66949e685..82816b2b5 100644 --- a/packages/bruno-tests/collection_oauth2/bruno.json +++ b/packages/bruno-tests/collection_oauth2/bruno.json @@ -1,9 +1,11 @@ { "version": "1", - "name": "collection_oauth2", + "name": "OAuth2 Demo", "type": "collection", "scripts": { - "moduleWhitelist": ["crypto"], + "moduleWhitelist": [ + "crypto" + ], "filesystemAccess": { "allow": true } @@ -15,4 +17,4 @@ "presets": { "requestType": "http" } -} +} \ No newline at end of file diff --git a/packages/bruno-tests/package.json b/packages/bruno-tests/package.json index ad819bf1d..129b12a51 100644 --- a/packages/bruno-tests/package.json +++ b/packages/bruno-tests/package.json @@ -18,7 +18,7 @@ }, "homepage": "https://github.com/usebruno/bruno-testbench#readme", "dependencies": { - "axios": "1.7.5", + "axios": "1.7.7", "body-parser": "1.20.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5",