- {isEditing ? 'Preview' : 'Edit'}
+
+
+
+
+ Documentation
+
+
+ {isEditing ? (
+ <>
+
+
+
+
+ Save
+
+ >
+ ) : (
+
+
+
+ )}
+
-
{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://usebruno.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/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js
index 718a38bd5..9ae6e1e07 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js
@@ -13,6 +13,7 @@ import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/acti
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
@@ -117,6 +118,7 @@ const Headers = ({ collection }) => {
)
}
collection={collection}
+ autocomplete={MimeTypes}
/>
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 f69e0b25a..000000000
--- a/packages/bruno-app/src/components/CollectionSettings/Info/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import StyledWrapper from './StyledWrapper';
-
-function countRequests(items) {
- let count = 0;
-
- function recurse(item) {
- if (item && typeof item === 'object') {
- if (item.type !== 'folder') {
- count++;
- }
- if (Array.isArray(item.items)) {
- item.items.forEach(recurse);
- }
- }
- }
-
- items.forEach(recurse);
-
- return count;
-}
-
-const Info = ({ 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 :
- {countRequests(collection.items)}
-
-
-
-
- );
-};
-
-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..ebe29a21a
--- /dev/null
+++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js
@@ -0,0 +1,82 @@
+import React from "react";
+import { getTotalRequestCountInCollection } from 'utils/collections/';
+import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
+import { areItemsLoading, getItemsLoadStats } from "utils/collections/index";
+import { useState } from "react";
+import ShareCollection from "components/ShareCollection/index";
+
+const Info = ({ collection }) => {
+ const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
+
+ const isCollectionLoading = areItemsLoading(collection);
+ const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
+ const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
+
+ const handleToggleShowShareCollectionModal = (value) => (e) => {
+ toggleShowShareCollectionModal(value);
+ }
+
+ return (
+
+
+
+ {/* Location Row */}
+
+
+
+
+
+
Location
+
+ {collection.pathname}
+
+
+
+
+ {/* Environments Row */}
+
+
+
+
+
+
Environments
+
+ {collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
+
+
+
+
+ {/* Requests Row */}
+
+
+
+
+
+
Requests
+
+ {
+ isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
+ }
+
+
+
+
+
+
+
+
+
+
Share
+
+ Share Collection
+
+
+
+ {showShareCollectionModal &&
}
+
+
+
+ );
+};
+
+export default Info;
\ No newline at end of file
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..4c7406580
--- /dev/null
+++ b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js
@@ -0,0 +1,80 @@
+import React from 'react';
+import { flattenItems } from "utils/collections";
+import { IconAlertTriangle } from '@tabler/icons';
+import StyledWrapper from "./StyledWrapper";
+import { useDispatch, useSelector } from 'react-redux';
+import { isItemARequest, itemIsOpenedInTabs } from 'utils/tabs/index';
+import { getDefaultRequestPaneTab } from 'utils/collections/index';
+import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
+import { hideHomePage } from 'providers/ReduxStore/slices/app';
+
+const RequestsNotLoaded = ({ collection }) => {
+ const dispatch = useDispatch();
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const flattenedItems = flattenItems(collection.items);
+ const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading);
+
+ if (!itemsFailedLoading?.length) {
+ return null;
+ }
+
+ const handleRequestClick = (item) => e => {
+ e.preventDefault();
+ if (isItemARequest(item)) {
+ dispatch(hideHomePage());
+ if (itemIsOpenedInTabs(item, tabs)) {
+ dispatch(
+ focusTab({
+ uid: item.uid
+ })
+ );
+ return;
+ }
+ dispatch(
+ addTab({
+ uid: item.uid,
+ collectionUid: collection.uid,
+ requestPaneTab: getDefaultRequestPaneTab(item)
+ })
+ );
+ return;
+ }
+ }
+
+ return (
+
+
+
+ Following requests were not loaded
+
+
+
+
+
+ Pathname
+
+
+ Size
+
+
+
+
+ {flattenedItems?.map((item, index) => (
+ item?.partial && !item?.loading ? (
+
+
+ {item?.pathname?.split(`${collection?.pathname}/`)?.[1]}
+
+
+ {item?.size?.toFixed?.(2)} MB
+
+
+ ) : null
+ ))}
+
+
+
+ );
+};
+
+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/Presets/index.js b/packages/bruno-app/src/components/CollectionSettings/Presets/index.js
index 734bd90ef..e16884e16 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Presets/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Presets/index.js
@@ -74,6 +74,7 @@ const PresetsSettings = ({ collection }) => {
id="request-url"
type="text"
name="requestUrl"
+ placeholder='Request URL'
className="block textbox"
autoComplete="off"
autoCorrect="off"
diff --git a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js
index 3df200e88..bb48cbdc0 100644
--- a/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/ProxySettings/index.js
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
-import Tooltip from 'components/Tooltip';
+import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
@@ -104,18 +104,15 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
Config
-
- global - use global proxy config
- enabled - use collection proxy config
- disable - disable proxy
+ global - use global proxy config
+ enabled - use collection proxy config
+ disable - disable proxy
- `}
- tooltipId="request-var"
- />
+
@@ -336,4 +333,4 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
);
};
-export default ProxySettings;
+export default ProxySettings;
\ No newline at end of file
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/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index 84af056f6..6fe979cbf 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -52,6 +52,7 @@ const Script = ({ collection }) => {
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
+ fontSize={get(preferences, 'font.codeFontSize')}
/>
@@ -64,6 +65,7 @@ const Script = ({ collection }) => {
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
+ fontSize={get(preferences, 'font.codeFontSize')}
/>
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/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
index c23294c74..d87a1dea4 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
@@ -36,6 +36,7 @@ const Tests = ({ collection }) => {
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
+ fontSize={get(preferences, 'font.codeFontSize')}
/>
diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js
similarity index 59%
rename from packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js
rename to packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js
index 7fd98347c..26459a3c6 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js
@@ -1,12 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- table {
- td {
- &:first-child {
- width: 120px;
- }
- }
+ max-width: 800px;
+
+ div.title {
+ color: var(--color-tab-inactive);
}
`;
diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/StyledWrapper.js
new file mode 100644
index 000000000..efacc8288
--- /dev/null
+++ b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/StyledWrapper.js
@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(3) {
+ width: 70px;
+ }
+ }
+ }
+
+ .btn-add-var {
+ font-size: 0.8125rem;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ background-color: inherit;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js
new file mode 100644
index 000000000..0341c6ecd
--- /dev/null
+++ b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js
@@ -0,0 +1,161 @@
+import React from 'react';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
+import SingleLineEditor from 'components/SingleLineEditor';
+import InfoTip from 'components/InfoTip';
+import StyledWrapper from './StyledWrapper';
+import toast from 'react-hot-toast';
+import { variableNameRegex } from 'utils/common/regex';
+import {
+ addCollectionVar,
+ deleteCollectionVar,
+ updateCollectionVar
+} from 'providers/ReduxStore/slices/collections/index';
+
+const VarsTable = ({ collection, vars, varType }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+
+ const addVar = () => {
+ dispatch(
+ addCollectionVar({
+ collectionUid: collection.uid,
+ type: varType
+ })
+ );
+ };
+
+ const onSave = () => dispatch(saveCollectionRoot(collection.uid));
+ const handleVarChange = (e, v, type) => {
+ const _var = cloneDeep(v);
+ switch (type) {
+ case 'name': {
+ const value = e.target.value;
+
+ if (variableNameRegex.test(value) === false) {
+ toast.error(
+ 'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
+ );
+ return;
+ }
+
+ _var.name = value;
+ break;
+ }
+ case 'value': {
+ _var.value = e.target.value;
+ break;
+ }
+ case 'enabled': {
+ _var.enabled = e.target.checked;
+ break;
+ }
+ }
+ dispatch(
+ updateCollectionVar({
+ type: varType,
+ var: _var,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleRemoveVar = (_var) => {
+ dispatch(
+ deleteCollectionVar({
+ type: varType,
+ varUid: _var.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ return (
+
+
+
+ + Add
+
+
+ );
+};
+export default VarsTable;
diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js
new file mode 100644
index 000000000..fae3ed613
--- /dev/null
+++ b/packages/bruno-app/src/components/CollectionSettings/Vars/index.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import get from 'lodash/get';
+import VarsTable from './VarsTable';
+import StyledWrapper from './StyledWrapper';
+import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+
+const Vars = ({ collection }) => {
+ const dispatch = useDispatch();
+ const requestVars = get(collection, 'root.request.vars.req', []);
+ const responseVars = get(collection, 'root.request.vars.res', []);
+ const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
+ return (
+
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Vars;
diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js
index 6cc42a09d..a4d011be3 100644
--- a/packages/bruno-app/src/components/CollectionSettings/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/index.js
@@ -12,10 +12,19 @@ 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 (
+
+
+
+ );
+};
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -29,10 +38,23 @@ const CollectionSettings = ({ collection }) => {
);
};
- const proxyConfig = get(collection, 'brunoConfig.proxy', {});
+ const root = collection?.root;
+ const hasScripts = root?.request?.script?.res || root?.request?.script?.req;
+ const hasTests = root?.request?.tests;
+ const hasDocs = root?.docs;
+ const headers = get(collection, 'root.request.headers', []);
+ const activeHeadersCount = headers.filter((header) => header.enabled).length;
+
+ const requestVars = get(collection, 'root.request.vars.req', []);
+ const responseVars = get(collection, 'root.request.vars.res', []);
+ const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
+ const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
+
+ const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
+
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.proxy = config;
@@ -74,9 +96,15 @@ const CollectionSettings = ({ collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
+ case 'overview': {
+ return
;
+ }
case 'headers': {
return
;
}
+ case 'vars': {
+ return
;
+ }
case 'auth': {
return
;
}
@@ -95,18 +123,13 @@ const CollectionSettings = ({ collection }) => {
case 'clientCert': {
return (
);
}
- case 'docs': {
- return
;
- }
- case 'info': {
- return
;
- }
}
};
@@ -119,32 +142,39 @@ const CollectionSettings = ({ collection }) => {
return (
+
setTab('overview')}>
+ Overview
+
setTab('headers')}>
Headers
+ {activeHeadersCount > 0 && {activeHeadersCount} }
+
+
setTab('vars')}>
+ Vars
+ {activeVarsCount > 0 && {activeVarsCount} }
setTab('auth')}>
Auth
+ {authMode !== 'none' && }
setTab('script')}>
Script
+ {hasScripts && }
setTab('tests')}>
Tests
+ {hasTests && }
setTab('presets')}>
Presets
setTab('proxy')}>
Proxy
+ {Object.keys(proxyConfig).length > 0 && }
setTab('clientCert')}>
Client Certificates
-
-
setTab('docs')}>
- Docs
-
-
setTab('info')}>
- Info
+ {clientCertConfig.length > 0 && }
diff --git a/packages/bruno-app/src/components/Cookies/ModifyCookieModal/StyledWrapper.js b/packages/bruno-app/src/components/Cookies/ModifyCookieModal/StyledWrapper.js
new file mode 100644
index 000000000..ec278887d
--- /dev/null
+++ b/packages/bruno-app/src/components/Cookies/ModifyCookieModal/StyledWrapper.js
@@ -0,0 +1,5 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div``;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Cookies/ModifyCookieModal/index.js b/packages/bruno-app/src/components/Cookies/ModifyCookieModal/index.js
new file mode 100644
index 000000000..5f7f66867
--- /dev/null
+++ b/packages/bruno-app/src/components/Cookies/ModifyCookieModal/index.js
@@ -0,0 +1,371 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import Modal from 'components/Modal/index';
+import { modifyCookie, addCookie, getParsedCookie, createCookieString } from 'providers/ReduxStore/slices/app';
+import { useDispatch } from 'react-redux';
+import toast from 'react-hot-toast';
+import ToggleSwitch from 'components/ToggleSwitch/index';
+import { IconInfoCircle } from '@tabler/icons';
+import moment from 'moment';
+import 'moment-timezone';
+import { Tooltip } from 'react-tooltip';
+import { isEmpty } from 'lodash';
+
+const removeEmptyValues = (obj) => {
+ return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== null && value !== undefined));
+};
+
+const ModifyCookieModal = ({ onClose, domain, cookie }) => {
+ const dispatch = useDispatch();
+ const [isRawMode, setIsRawMode] = useState(false);
+ const [cookieString, setCookieString] = useState('');
+ const initialParseRef = useRef(false);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ ...(cookie ? cookie : {}),
+ key: cookie?.key || '',
+ value: cookie?.value || '',
+ path: cookie?.path || '/',
+ domain: cookie?.domain || domain || '',
+ expires: cookie?.expires ? moment(cookie.expires).format(moment.HTML5_FMT.DATETIME_LOCAL) : '',
+ secure: cookie?.secure || false,
+ httpOnly: cookie?.httpOnly || false
+ },
+ validationSchema: Yup.object({
+ key: Yup.string().required('Key is required'),
+ value: Yup.string().required('Value is required'),
+ domain: Yup.string().required('Domain is required'),
+ secure: Yup.boolean(),
+ httpOnly: Yup.boolean(),
+ expires: Yup.mixed()
+ .nullable()
+ .transform((value) => {
+ if (!value || value === '') return null;
+ return moment(value).isValid() ? moment(value).toDate() : null;
+ })
+ .test('future-date', 'Expiration date must be in the future', (value) => {
+ if (!value) return true;
+ return moment(value).isAfter(moment());
+ })
+ }),
+ onSubmit: (values) => {
+ const modValues = removeEmptyValues({
+ ...(cookie ? cookie : {}),
+ ...values,
+ expires: values.expires
+ ? moment(values.expires).isValid()
+ ? moment(values.expires).toDate()
+ : Infinity
+ : Infinity
+ });
+
+ handleCookieDispatch(cookie, domain, modValues, onClose);
+ }
+ });
+
+ const title = cookie ? 'Modify Cookie' : 'Add Cookie';
+
+ const handleCookieDispatch = (cookie, domain, modValues, onClose) => {
+ if (cookie) {
+ dispatch(modifyCookie(domain, cookie, modValues))
+ .then(() => {
+ toast.success('Cookie modified successfully');
+ onClose();
+ })
+ .catch((err) => {
+ toast.error('An error occurred while modifying cookie');
+ console.error(err);
+ });
+ } else {
+ dispatch(addCookie(domain, modValues))
+ .then(() => {
+ toast.success('Cookie added successfully');
+ onClose();
+ })
+ .catch((err) => {
+ toast.error('An error occurred while adding cookie');
+ console.error(err);
+ });
+ }
+ };
+
+ const onSubmit = async () => {
+ try {
+ if (isRawMode) {
+ const cookieObj = await dispatch(getParsedCookie(cookieString));
+
+ const modifiedCookie = removeEmptyValues({
+ ...formik.values,
+ ...cookieObj,
+ expires: cookieObj?.expires
+ ? moment(cookieObj.expires).isValid()
+ ? moment(cookieObj.expires).toDate()
+ : Infinity
+ : Infinity
+ });
+
+ if (!cookieObj) {
+ toast.error('Please enter a valid cookie string');
+ return;
+ }
+
+ const validationErrors = await formik.setValues(
+ (values) => ({
+ ...values,
+ ...modifiedCookie,
+ expires:
+ modifiedCookie?.expires && moment(modifiedCookie.expires).isValid()
+ ? moment(new Date(modifiedCookie.expires)).format(moment.HTML5_FMT.DATETIME_LOCAL)
+ : ''
+ }),
+ true
+ );
+
+ if (!isEmpty(validationErrors)) {
+ toast.error(Object.values(validationErrors).join("\n"));
+ return;
+ }
+
+ handleCookieDispatch(cookie, domain, modifiedCookie, onClose);
+ } else {
+ formik.handleSubmit();
+ }
+ } catch (error) {
+ const errMsg = error.message || 'An error occurred while parsing cookie string';
+ toast.error(errMsg);
+ }
+ };
+
+ useEffect(() => {
+ if (!isRawMode) return;
+ const loadCookieString = async () => {
+ if (cookie) {
+ const str = await dispatch(createCookieString(cookie));
+ setCookieString(str);
+ }
+ return '';
+ };
+
+ loadCookieString();
+ }, [cookie, isRawMode]);
+
+ // create the cookieString when raw mode is enabled
+ useEffect(() => {
+ if (isRawMode) {
+ const createCookieStr = async () => {
+ const str = await dispatch(createCookieString(formik.values));
+ setCookieString(str);
+ };
+
+ createCookieStr();
+ }
+ }, [isRawMode, formik.values]);
+
+ useEffect(() => {
+ // Reset the ref when raw mode changes
+ if (isRawMode) {
+ initialParseRef.current = false;
+ return;
+ }
+
+ const setParsedCookie = async () => {
+ if (!isRawMode && cookieString && !initialParseRef.current) {
+ initialParseRef.current = true;
+
+ try {
+ const cookieObj = await dispatch(getParsedCookie(cookieString));
+
+ if (!cookieObj) return;
+
+ formik.setValues(
+ (values) => ({
+ ...values,
+ ...removeEmptyValues(cookieObj),
+ expires:
+ cookieObj?.expires && moment(cookieObj.expires).isValid()
+ ? moment(new Date(cookieObj.expires)).format(moment.HTML5_FMT.DATETIME_LOCAL)
+ : ''
+ }),
+ true
+ );
+ } catch (error) {
+ const errMsg = error.message || 'An error occurred while parsing cookie string';
+ toast.error(errMsg);
+ }
+ }
+ };
+
+ setParsedCookie();
+ }, [isRawMode, cookieString, dispatch, formik]);
+
+ return (
+
+ {title}
+
+ {
+ setIsRawMode(e.target.checked);
+ }}
+ />
+ Edit Raw
+
+
+ }
+ >
+
+
+ );
+};
+
+export default ModifyCookieModal;
diff --git a/packages/bruno-app/src/components/Cookies/StyledWrapper.js b/packages/bruno-app/src/components/Cookies/StyledWrapper.js
index 102558382..5e3cdd125 100644
--- a/packages/bruno-app/src/components/Cookies/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Cookies/StyledWrapper.js
@@ -11,6 +11,65 @@ const Wrapper = styled.div`
user-select: none;
}
}
+
+ &.header {
+ input {
+ padding: 0.3rem 0.5rem;
+ }
+ }
+
+ .textbox {
+ line-height: 1.42857143;
+ border: 1px solid #ccc;
+ padding: 0.45rem;
+ box-shadow: none;
+ border-radius: 0px;
+ outline: none;
+ box-shadow: none;
+ transition: border-color ease-in-out 0.1s;
+ border-radius: 3px;
+ background-color: ${(props) => props.theme.modal.input.bg};
+ border: 1px solid ${(props) => props.theme.modal.input.border};
+
+ &:focus {
+ border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important;
+ outline: none !important;
+ }
+ }
+
+ .scroll-box {
+ max-height: 500px;
+ overflow-y: auto;
+
+ background:
+ /* Shadow Cover TOP */
+ linear-gradient(
+ ${(props) => props.theme.modal.body.bg} 20%,
+ rgba(255, 255, 255, 0)
+ ) center top,
+
+ /* Shadow Cover BOTTOM */
+ linear-gradient(
+ rgba(255, 255, 255, 0),
+ ${(props) => props.theme.modal.body.bg} 80%
+ ) center bottom,
+
+ /* Shadow TOP */
+ linear-gradient(
+ rgba(0, 0, 0, 0.1) 0%,
+ rgba(0, 0, 0, 0) 100%
+ ) center top,
+
+ /* Shadow BOTTOM */
+ linear-gradient(
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 0.1) 100%
+ ) center bottom;
+
+ background-repeat: no-repeat;
+ background-size: 100% 30px, 100% 30px, 100% 10px, 100% 10px;
+ background-attachment: local, local, scroll, scroll;
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/Cookies/index.js b/packages/bruno-app/src/components/Cookies/index.js
index f7420bed8..2afa3d37d 100644
--- a/packages/bruno-app/src/components/Cookies/index.js
+++ b/packages/bruno-app/src/components/Cookies/index.js
@@ -1,53 +1,331 @@
-import React from 'react';
+import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
-import { IconTrash } from '@tabler/icons';
-import { deleteCookiesForDomain } from 'providers/ReduxStore/slices/app';
+import Accordion from 'components/Accordion/index';
+import { IconTrash, IconEdit, IconCirclePlus, IconCookieOff, IconAlertTriangle, IconSearch } from '@tabler/icons';
+import { deleteCookiesForDomain, deleteCookie } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
-
+import ModifyCookieModal from 'components/Cookies/ModifyCookieModal/index';
import StyledWrapper from './StyledWrapper';
+import moment from 'moment';
+import { Tooltip } from 'react-tooltip';
+
+const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
+
+
+
+
Hold on..
+
+
+ Are you sure you want to clear all cookies for the domain {domain}?
+
+
+
+
+
+ Close
+
+
+
+
+ Clear All
+
+
+
+
+);
+
+const DeleteCookieModal = ({ onClose, cookieName, onDelete }) => (
+
+
+
+
Hold on..
+
+
+ Are you sure you want to delete the cookie {cookieName}?
+
+
+
+
+
+ Close
+
+
+
+
+ Delete
+
+
+
+
+);
const CollectionProperties = ({ onClose }) => {
const dispatch = useDispatch();
const cookies = useSelector((state) => state.app.cookies) || [];
+ const [isModifyCookieModalOpen, setIsModifyCookieModalOpen] = useState(false);
+ const [currentDomain, setCurrentDomain] = useState(null);
+ const [cookieToEdit, setCookieToEdit] = useState(null);
- const handleDeleteDomain = (domain) => {
- dispatch(deleteCookiesForDomain(domain))
- .then(() => {
- toast.success('Domain deleted successfully');
- })
- .catch((err) => console.log(err) && toast.error('Failed to delete domain'));
+ const [domainToClear, setDomainToClear] = useState(null);
+ const [cookieToDelete, setCookieToDelete] = useState(null);
+ const [searchText, setSearchText] = useState(null);
+
+ const handleAddCookie = (domain) => {
+ if(domain) setCurrentDomain(domain);
+ setIsModifyCookieModalOpen(true);
};
+ const handleEditCookie = (domain, cookie) => {
+ setCurrentDomain(domain);
+ setCookieToEdit(cookie);
+ setIsModifyCookieModalOpen(true);
+ };
+
+ const handleClearDomainCookies = (domain) => {
+ setDomainToClear(domain);
+ };
+
+ const clearDomainCookiesAction = () => {
+ dispatch(deleteCookiesForDomain(domainToClear))
+ .then(() => {
+ toast.success('Domain cookies cleared successfully');
+ })
+ .catch((err) => console.log(err) && toast.error('Failed to clear domain cookies'));
+ setDomainToClear(null);
+ };
+
+ const handleDeleteCookie = (domain, path, key) => {
+ setCookieToDelete({ key, domain, path });
+ };
+
+ const deleteCookieAction = () => {
+ if (cookieToDelete) {
+ const { domain, path, key } = cookieToDelete;
+ dispatch(deleteCookie(domain, path, key))
+ .then(() => {
+ toast.success('Cookie deleted successfully');
+ })
+ .catch((err) => console.log(err) && toast.error('Failed to delete cookie'));
+ }
+ setCookieToDelete(null);
+ };
+
+ const filteredCookies = useMemo(() => {
+ if (!searchText) return cookies;
+
+ return cookies.filter((cookie) =>
+ cookie.domain.toLowerCase().includes(searchText.toLowerCase())
+ );
+ }, [cookies, searchText]);
+
+ const shouldShowHeader = cookies && cookies.length > 0;
+
return (
-
-
-
-
-
- Domain
- Cookie
-
- Actions
-
-
-
-
- {cookies.map((cookie) => (
-
- {cookie.domain}
- {cookie.cookieString}
-
- handleDeleteDomain(cookie.domain)}>
-
-
-
-
- ))}
-
-
-
-
+ <>
+
+ Cookies
+ setSearchText(e.target.value)}
+ className="block textbox non-passphrase-input ml-auto font-normal"
+ />
+ {
+ e.stopPropagation();
+ handleAddCookie();
+ }}
+ >
+
+ Add Cookie
+
+
+ ) : null}
+ >
+
+ {!cookies || !cookies.length ? (
+ // No cookies found
+
+
+
No cookies found
+
Add cookies to get started
+
{
+ e.stopPropagation();
+ handleAddCookie();
+ }}
+ >
+
+ Add Cookie
+
+
+ ) : cookies.length && !filteredCookies.length ? (
+ // No search results
+
+
+
No search results
+
Try a different search term
+
+ ) : (
+ // Show cookies list
+
+
+ {filteredCookies.map((domainWithCookies, i) => (
+
+
+
+
{domainWithCookies.domain}
+
+ ({domainWithCookies.cookies.length}{' '}
+ {domainWithCookies.cookies.length === 1 ? 'cookie' : 'cookies'})
+
+
+ {
+ e.stopPropagation();
+ handleAddCookie(domainWithCookies.domain);
+ }}
+ >
+
+
+ {
+ e.stopPropagation();
+ handleClearDomainCookies(domainWithCookies.domain);
+ }}
+ className="text-gray-950 dark:text-white dark:hover:hover:text-red-600 hover:text-red-600 mr-2"
+ >
+
+
+
+
+
+
+
+
+
+
+ Name
+ Value
+ Path
+ Expires
+ Secure
+ HTTP Only
+ Actions
+
+
+
+ {domainWithCookies.cookies.map((cookie) => (
+
+
+ {cookie.key}
+
+
+
+ {cookie.value}
+
+
+ {cookie.path || '/'}
+
+
+ {cookie.expires && moment(cookie.expires).isValid()
+ ? new Date(cookie.expires).toLocaleString()
+ : 'Session'}
+
+ {cookie.expires && moment(cookie.expires).isValid() && (
+
+ )}
+
+ {cookie.secure ? '✓' : ''}
+ {cookie.httpOnly ? '✓' : ''}
+
+
+ {
+ e.stopPropagation();
+ handleEditCookie(domainWithCookies.domain, cookie);
+ }}
+ className="text-gray-700 hover:text-gray-950
+ dark:text-white dark:hover:text-gray-300"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleDeleteCookie(domainWithCookies.domain, cookie.path, cookie.key);
+ }}
+ className="text-gray-950 dark:text-white dark:hover:hover:text-red-600 hover:text-red-600"
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ {isModifyCookieModalOpen && (
+
{
+ setCookieToEdit(null);
+ setCurrentDomain(null);
+ setIsModifyCookieModalOpen(false);
+ }}
+ domain={currentDomain}
+ cookie={cookieToEdit}
+ />
+ )}
+ {domainToClear ? (
+ setDomainToClear(null)}
+ domain={domainToClear}
+ onClear={clearDomainCookiesAction}
+ />
+ ) : null}
+ {cookieToDelete ? (
+ setCookieToDelete(null)}
+ cookieName={cookieToDelete.key}
+ onDelete={deleteCookieAction}
+ />
+ ) : null}
+ >
);
};
diff --git a/packages/bruno-app/src/components/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
index f0ffee808..af80d4c08 100644
--- a/packages/bruno-app/src/components/Documentation/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
@@ -1,17 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- div.CodeMirror {
- /* todo: find a better way */
- height: calc(100vh - 240px);
-
- .CodeMirror-scroll {
- padding-bottom: 0px;
- }
- }
.editing-mode {
cursor: pointer;
- color: ${(props) => props.theme.colors.text.yellow};
}
`;
diff --git a/packages/bruno-app/src/components/Documentation/index.js b/packages/bruno-app/src/components/Documentation/index.js
index d4b790965..0af0d7588 100644
--- a/packages/bruno-app/src/components/Documentation/index.js
+++ b/packages/bruno-app/src/components/Documentation/index.js
@@ -37,8 +37,8 @@ const Documentation = ({ item, collection }) => {
}
return (
-
-
+
+
{isEditing ? 'Preview' : 'Edit'}
@@ -47,13 +47,14 @@ const Documentation = ({ item, collection }) => {
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
+ fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
/>
) : (
-
+
)}
);
diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
index 6ad94e289..f784cf527 100644
--- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
@@ -36,14 +36,26 @@ const Wrapper = styled.div`
padding: 0.35rem 0.6rem;
cursor: pointer;
+ &.active {
+ color: ${(props) => props.theme.colors.text.yellow} !important;
+ .icon {
+ color: ${(props) => props.theme.colors.text.yellow} !important;
+ }
+ }
+
.icon {
color: ${(props) => props.theme.dropdown.iconColor};
}
- &:hover {
+ &:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
+ &:disabled {
+ cursor: not-allowed;
+ color: gray;
+ }
+
&.border-top {
border-top: solid 1px ${(props) => props.theme.dropdown.separator};
}
diff --git a/packages/bruno-app/src/components/Dropdown/index.js b/packages/bruno-app/src/components/Dropdown/index.js
index e4f48724c..3deb0e849 100644
--- a/packages/bruno-app/src/components/Dropdown/index.js
+++ b/packages/bruno-app/src/components/Dropdown/index.js
@@ -2,9 +2,9 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
-const Dropdown = ({ icon, children, onCreate, placement }) => {
+const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
return (
-
+
{
const Icon = forwardRef((props, ref) => {
return (
- {activeEnvironment ? activeEnvironment.name : 'No Environment'}
+
{activeEnvironment ? activeEnvironment.name : 'No Environment'}
);
@@ -53,10 +53,11 @@ const EnvironmentSelector = ({ collection }) => {
} placement="bottom-end">
+
Collection Environments
{environments && environments.length
? environments.map((e) => (
{
onSelect(e);
@@ -77,7 +78,10 @@ const EnvironmentSelector = ({ collection }) => {
No Environment
-
+
{
+ handleSettingsIconClick();
+ dropdownTippyRef.current.hide();
+ }}>
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js
index a9fdf3b4a..586f874bb 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js
@@ -44,7 +44,7 @@ const CopyEnvironment = ({ collection, environment, onClose }) => {
return (
-
+ e.preventDefault()}>
New Environment Name
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
index e6947bd3a..4f3dcb5ba 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
@@ -1,15 +1,21 @@
import React, { useEffect, useRef } from 'react';
-import Portal from 'components/Portal';
-import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import { addEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
+import Portal from 'components/Portal';
+import Modal from 'components/Modal';
+import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+
+ const validateEnvironmentName = (name) => {
+ return !collection?.environments?.some((env) => env?.name?.toLowerCase().trim() === name?.toLowerCase().trim());
+ };
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@@ -17,9 +23,14 @@ const CreateEnvironment = ({ collection, onClose }) => {
},
validationSchema: Yup.object({
name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
+ .min(1, 'Must be at least 1 character')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .required('Name is required')
+ .test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addEnvironment(values.name, collection.uid))
@@ -27,7 +38,7 @@ const CreateEnvironment = ({ collection, onClose }) => {
toast.success('Environment created in collection');
onClose();
})
- .catch(() => toast.error('An error occurred while created the environment'));
+ .catch(() => toast.error('An error occurred while creating the environment'));
}
});
@@ -50,24 +61,26 @@ const CreateEnvironment = ({ collection, onClose }) => {
handleConfirm={onSubmit}
handleCancel={onClose}
>
-
+ e.preventDefault()}>
Environment Name
-
+
+
+
{formik.touched.name && formik.errors.name ? (
{formik.errors.name}
) : null}
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
index 7eec1394c..5f4e34d8f 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
@@ -39,6 +39,11 @@ const Wrapper = styled.div`
font-size: 0.8125rem;
}
+ .tooltip-mod {
+ font-size: 11px !important;
+ width: 150px !important;
+ }
+
input[type='text'] {
width: 100%;
border: solid 1px transparent;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
index 1f36d05ea..c777aa85f 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -1,21 +1,30 @@
-import React from 'react';
-import { IconTrash } from '@tabler/icons';
+import React, { useRef, useEffect } from 'react';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
+import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
-import { maskInputValue } from 'utils/collections';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import cloneDeep from 'lodash/cloneDeep';
import toast from 'react-hot-toast';
+import { Tooltip } from 'react-tooltip';
+import { getGlobalEnvironmentVariables } from 'utils/collections';
-const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => {
+const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const addButtonRef = useRef(null);
+ const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
+
+ let _collection = cloneDeep(collection);
+
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ _collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -59,14 +68,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
- if (!meta.error) {
+ const id = uuid();
+ if (!meta.error || !meta.touched) {
return null;
}
-
return (
-
- {meta.error}
-
+
+
+
+
);
};
@@ -82,10 +92,31 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
formik.setFieldValue(formik.values.length, newVariable, false);
};
+ const onActivate = () => {
+ dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
+ .then(() => {
+ if (environment) {
+ toast.success(`Environment changed to ${environment.name}`);
+ onClose();
+ } else {
+ toast.success(`No Environments are active now`);
+ }
+ })
+ .catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
+ };
+
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
+ useEffect(() => {
+ if (formik.dirty) {
+ // Smooth scrolling to the changed parameter is temporarily disabled
+ // due to UX issues when editing the first row in a long list of environment variables.
+ // addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [formik.values, formik.dirty]);
+
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
};
@@ -96,10 +127,10 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
-
-
-
- + Add Variable
-
+
+
+ + Add Variable
+
+
-
-
+
+
+
Save
-
+
+
Reset
+
+
+ Activate
+
);
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
index f9fca74ec..17c0bbcf0 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
-const EnvironmentDetails = ({ environment, collection, setIsModified }) => {
+const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -38,7 +38,7 @@ const EnvironmentDetails = ({ environment, collection, setIsModified }) => {
-
+
);
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
index 330ae082c..dd9761532 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
@@ -23,6 +23,10 @@ const StyledWrapper = styled.div`
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
&:hover {
text-decoration: none;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
index 4517bd8d3..278a7f25d 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
@@ -8,8 +8,10 @@ import ImportEnvironment from '../ImportEnvironment';
import ManageSecrets from '../ManageSecrets';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
+import ToolHint from 'components/ToolHint';
+import { isEqual } from 'lodash';
-const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified }) => {
+const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose }) => {
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
@@ -23,6 +25,11 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
useEffect(() => {
if (selectedEnvironment) {
+ const _selectedEnvironment = environments?.find(env => env?.uid === selectedEnvironment?.uid);
+ const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
+ if (hasSelectedEnvironmentChanged) {
+ setSelectedEnvironment(_selectedEnvironment);
+ }
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
@@ -103,13 +110,15 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
{environments &&
environments.length &&
environments.map((env) => (
- handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
- >
- {env.name}
-
+
+ handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
+ >
+ {env.name}
+
+
))}
handleCreateEnvClick()}>
+ Create
@@ -132,6 +141,7 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
collection={collection}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
+ onClose={onClose}
/>
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js
index 5caba79b2..2a4ef2297 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js
@@ -1,24 +1,41 @@
import React from 'react';
import Portal from 'components/Portal';
+import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { toastError } from 'utils/common/error';
-import Modal from 'components/Modal';
+import { IconDatabaseImport } from '@tabler/icons';
-const ImportEnvironment = ({ onClose, collection }) => {
+const ImportEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
- .then((environment) => {
- dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
- .then(() => {
- toast.success('Environment imported successfully');
- onClose();
- })
- .catch(() => toast.error('An error occurred while importing the environment'));
+ .then((environments) => {
+ environments
+ .filter((env) =>
+ env.name && env.name !== 'undefined'
+ ? true
+ : () => {
+ toast.error('Failed to import environment: env has no name');
+ return false;
+ }
+ )
+ .map((environment) => {
+ dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
+ .then(() => {
+ toast.success('Environment imported successfully');
+ })
+ .catch((error) => {
+ toast.error('An error occurred while importing the environment');
+ console.error(error);
+ });
+ });
+ })
+ .then(() => {
+ onClose();
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
@@ -26,11 +43,14 @@ const ImportEnvironment = ({ onClose, collection }) => {
return (
-
-
- Postman Environment
-
-
+
+
+ Import your Postman environments
+
);
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js
index 84572db90..fee403d8a 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js
@@ -6,6 +6,7 @@ import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
+import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment, collection }) => {
const dispatch = useDispatch();
@@ -18,10 +19,17 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('name is required')
}),
onSubmit: (values) => {
+ if (values.name === environment.name) {
+ return;
+ }
dispatch(renameEnvironment(values.name, environment.uid, collection.uid))
.then(() => {
toast.success('Environment renamed successfully');
@@ -50,7 +58,7 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
handleConfirm={onSubmit}
handleCancel={onClose}
>
-
+ e.preventDefault()}>
Environment Name
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
index 0a3f7e25b..81c663caf 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
@@ -4,45 +4,61 @@ import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ImportEnvironment from './ImportEnvironment';
+import { IconFileAlert } from '@tabler/icons';
+
+export const SharedButton = ({ children, className, onClick }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const DefaultTab = ({ setTab }) => {
+ return (
+
+
+
No environments found
+
+ Get started by using the following buttons :
+
+
+ setTab('create')}>
+ Create Environment
+
+
+ Or
+
+ setTab('import')}>
+ Import Environment
+
+
+
+ );
+};
const EnvironmentSettings = ({ collection, onClose }) => {
const [isModified, setIsModified] = useState(false);
const { environments } = collection;
- const [openCreateModal, setOpenCreateModal] = useState(false);
- const [openImportModal, setOpenImportModal] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
-
+ const [tab, setTab] = useState('default');
if (!environments || !environments.length) {
return (
-
- {openCreateModal && setOpenCreateModal(false)} />}
- {openImportModal && setOpenImportModal(false)} />}
-
-
No environments found!
-
setOpenCreateModal(true)}
- >
- Create Environment
-
-
-
Or
-
-
setOpenImportModal(true)}
- >
- Import Environment
-
-
+
+ {tab === 'create' ? (
+ setTab('default')} />
+ ) : tab === 'import' ? (
+ setTab('default')} />
+ ) : (
+ <>>
+ )}
+
);
@@ -56,6 +72,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
+ onClose={onClose}
/>
);
diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js
index a7b67264d..26969dde3 100644
--- a/packages/bruno-app/src/components/FilePickerEditor/index.js
+++ b/packages/bruno-app/src/components/FilePickerEditor/index.js
@@ -1,15 +1,13 @@
import React from 'react';
-import path from 'path';
+import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
-import slash from 'utils/common/slash';
-const FilePickerEditor = ({ value, onChange, collection }) => {
- value = value || [];
+const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const dispatch = useDispatch();
- const filenames = value
+ const filenames = (isSingleFilePicker ? [value] : value || [])
.filter((v) => v != null && v != '')
.map((v) => {
const separator = isWindowsOS() ? '\\' : '/';
@@ -20,7 +18,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const title = filenames.map((v) => `- ${v}`).join('\n');
const browse = () => {
- dispatch(browseFiles())
+ dispatch(browseFiles([], [!isSingleFilePicker ? "multiSelections": ""]))
.then((filePaths) => {
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
@@ -28,13 +26,13 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
- return path.relative(slash(collectionDir), slash(filePath));
+ return path.relative(collectionDir, filePath);
}
return filePath;
});
- onChange(filePaths);
+ onChange(isSingleFilePicker ? filePaths[0] : filePaths);
})
.catch((error) => {
console.error(error);
@@ -42,14 +40,14 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
};
const clear = () => {
- onChange('');
+ onChange(isSingleFilePicker ? '' : []);
};
const renderButtonText = (filenames) => {
if (filenames.length == 1) {
return filenames[0];
}
- return filenames.length + ' files selected';
+ return filenames.length + ' file(s) selected';
};
return filenames.length > 0 ? (
@@ -66,9 +64,9 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
) : (
- Select Files
+ {isSingleFilePicker ? 'Select File' : 'Select Files'}
);
};
-export default FilePickerEditor;
+export default FilePickerEditor;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js
new file mode 100644
index 000000000..ba243d42b
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ label {
+ font-size: 0.8125rem;
+ }
+ .single-line-editor-wrapper {
+ max-width: 400px;
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ }
+ .inherit-mode-text {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+ .auth-mode-label {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+`;
+
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js
new file mode 100644
index 000000000..89b109f82
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js
@@ -0,0 +1,226 @@
+import React from 'react';
+import get from 'lodash/get';
+import StyledWrapper from './StyledWrapper';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
+import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
+import { useDispatch } from 'react-redux';
+import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
+import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
+import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
+import AuthMode from '../AuthMode';
+import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
+import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
+import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
+import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
+import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
+import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
+import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
+import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
+
+const GrantTypeComponentMap = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+
+ const save = () => {
+ dispatch(saveFolderRoot(collection.uid, folder.uid));
+ };
+
+ let request = get(folder, 'root.request', {});
+ const grantType = get(request, 'auth.oauth2.grantType', 'authorization_code');
+
+ switch (grantType) {
+ case 'password':
+ return ;
+ case 'authorization_code':
+ return ;
+ case 'client_credentials':
+ return ;
+ default:
+ return TBD
;
+ }
+};
+
+const Auth = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ let request = get(folder, 'root.request', {});
+ const authMode = get(folder, 'root.request.auth.mode');
+
+ const getTreePathFromCollectionToFolder = (collection, _folder) => {
+ let path = [];
+ let item = findItemInCollection(collection, _folder?.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
+ }
+ return path;
+ };
+
+ const getEffectiveAuthSource = () => {
+ if (authMode !== 'inherit') return null;
+
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ name: 'Collection',
+ auth: collectionAuth
+ };
+
+ // Get path from collection to current folder
+ const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
+
+ // Check parent folders to find closest auth configuration
+ // Skip the last item which is the current folder
+ for (let i = 0; i < folderTreePath.length - 1; i++) {
+ const parentFolder = folderTreePath[i];
+ if (parentFolder.type === 'folder') {
+ const folderAuth = get(parentFolder, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ name: parentFolder.name,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+ };
+
+ const handleSave = () => {
+ dispatch(saveFolderRoot(collection.uid, folder.uid));
+ };
+
+ const getAuthView = () => {
+ switch (authMode) {
+ case 'basic': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'bearer': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'digest': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'ntlm': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'wsse': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'apikey': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'awsv4': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'oauth2': {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ case 'inherit': {
+ const source = getEffectiveAuthSource();
+ return (
+ <>
+
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ >
+ );
+ }
+ case 'none': {
+ return null;
+ }
+ default:
+ return null;
+ }
+ };
+
+
+ return (
+
+
+ Configures authentication for the entire folder. This applies to all requests using the{' '}
+ Inherit option in the Auth tab.
+
+
+ {getAuthView()}
+
+
+ Save
+
+
+
+ );
+};
+
+export default Auth;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js
new file mode 100644
index 000000000..2a42257eb
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .auth-mode-selector {
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8125rem;
+ }
+
+ .auth-mode-label {
+ color: ${({ theme }) => theme.colors.text};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js
new file mode 100644
index 000000000..36377973a
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js
@@ -0,0 +1,134 @@
+import React, { useRef, forwardRef } from 'react';
+import get from 'lodash/get';
+import { IconCaretDown } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import { useDispatch } from 'react-redux';
+import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
+import { humanizeRequestAuthMode } from 'utils/collections';
+import StyledWrapper from './StyledWrapper';
+
+const AuthMode = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const authMode = get(folder, 'root.request.auth.mode');
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+ {humanizeRequestAuthMode(authMode)}
+
+ );
+ });
+
+ const onModeChange = (value) => {
+ dispatch(
+ updateFolderAuthMode({
+ mode: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ return (
+
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('awsv4');
+ }}
+ >
+ AWS Sig v4
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('basic');
+ }}
+ >
+ Basic Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('bearer');
+ }}
+ >
+ Bearer Token
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('digest');
+ }}
+ >
+ Digest Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('ntlm');
+ }}
+ >
+ NTLM Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('oauth2');
+ }}
+ >
+ OAuth 2.0
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('wsse');
+ }}
+ >
+ WSSE Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('apikey');
+ }}
+ >
+ API Key
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('inherit');
+ }}
+ >
+ Inherit
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('none');
+ }}
+ >
+ No Auth
+
+
+
+
+ );
+};
+
+export default AuthMode;
diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js
new file mode 100644
index 000000000..f159d94dc
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Documentation/StyledWrapper.js
@@ -0,0 +1,10 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .editing-mode {
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js
new file mode 100644
index 000000000..964afdece
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js
@@ -0,0 +1,66 @@
+import 'github-markdown-css/github-markdown.css';
+import get from 'lodash/get';
+import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
+import { useTheme } from 'providers/Theme';
+import { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import Markdown from 'components/MarkDown';
+import CodeEditor from 'components/CodeEditor';
+import StyledWrapper from './StyledWrapper';
+
+const Documentation = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const { displayedTheme } = useTheme();
+ const preferences = useSelector((state) => state.app.preferences);
+ const [isEditing, setIsEditing] = useState(false);
+ const docs = get(folder, 'root.docs', '');
+
+ const toggleViewMode = () => {
+ setIsEditing((prev) => !prev);
+ };
+
+ const onEdit = (value) => {
+ dispatch(
+ updateFolderDocs({
+ folderUid: folder.uid,
+ collectionUid: collection.uid,
+ docs: value
+ })
+ );
+ };
+
+ const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+
+ if (!folder) {
+ return null;
+ }
+
+ return (
+
+
+ {isEditing ? 'Preview' : 'Edit'}
+
+
+ {isEditing ? (
+
+
+
+ Save
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default Documentation;
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js
new file mode 100644
index 000000000..9f723cb81
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js
@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(3) {
+ width: 70px;
+ }
+ }
+ }
+
+ .btn-add-header {
+ font-size: 0.8125rem;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ background-color: inherit;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
new file mode 100644
index 000000000..0f6e05f1f
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
@@ -0,0 +1,154 @@
+import React from 'react';
+import get from 'lodash/get';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import SingleLineEditor from 'components/SingleLineEditor';
+import StyledWrapper from './StyledWrapper';
+import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
+
+const Headers = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const headers = get(folder, 'root.request.headers', []);
+
+ const addHeader = () => {
+ dispatch(
+ addFolderHeader({
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+ const handleHeaderValueChange = (e, _header, type) => {
+ const header = cloneDeep(_header);
+ switch (type) {
+ case 'name': {
+ header.name = e.target.value;
+ break;
+ }
+ case 'value': {
+ header.value = e.target.value;
+ break;
+ }
+ case 'enabled': {
+ header.enabled = e.target.checked;
+ break;
+ }
+ }
+ dispatch(
+ updateFolderHeader({
+ header: header,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleRemoveHeader = (header) => {
+ dispatch(
+ deleteFolderHeader({
+ headerUid: header.uid,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ return (
+
+
+ Request headers that will be sent with every request inside this folder.
+
+
+
+ + Add Header
+
+
+
+
+ Save
+
+
+
+ );
+};
+export default Headers;
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Script/StyledWrapper.js
similarity index 62%
rename from packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js
rename to packages/bruno-app/src/components/FolderSettings/Script/StyledWrapper.js
index 9f7583222..66ba1ed3d 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js
+++ b/packages/bruno-app/src/components/FolderSettings/Script/StyledWrapper.js
@@ -2,8 +2,11 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
- /* todo: find a better way */
- height: calc(100vh - 220px);
+ height: inherit;
+ }
+
+ div.title {
+ color: var(--color-tab-inactive);
}
`;
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js
new file mode 100644
index 000000000..628fa5cb5
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import get from 'lodash/get';
+import { useDispatch, useSelector } from 'react-redux';
+import CodeEditor from 'components/CodeEditor';
+import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import { useTheme } from 'providers/Theme';
+import StyledWrapper from './StyledWrapper';
+
+const Script = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const requestScript = get(folder, 'root.request.script.req', '');
+ const responseScript = get(folder, 'root.request.script.res', '');
+
+ const { displayedTheme } = useTheme();
+ const preferences = useSelector((state) => state.app.preferences);
+
+ const onRequestScriptEdit = (value) => {
+ dispatch(
+ updateFolderRequestScript({
+ script: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const onResponseScriptEdit = (value) => {
+ dispatch(
+ updateFolderResponseScript({
+ script: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleSave = () => {
+ dispatch(saveFolderRoot(collection.uid, folder.uid));
+ };
+
+ return (
+
+
+ Pre and post-request scripts that will run before and after any request inside this folder is sent.
+
+
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Script;
diff --git a/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js
new file mode 100644
index 000000000..b88a31e0d
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/StyledWrapper.js
@@ -0,0 +1,46 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ max-width: 800px;
+
+ div.tabs {
+ div.tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+ }
+ }
+ table {
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+
+ li {
+ background-color: ${(props) => props.theme.bg} !important;
+ }
+ }
+ }
+
+ .muted {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Tests/StyledWrapper.js
new file mode 100644
index 000000000..ec278887d
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Tests/StyledWrapper.js
@@ -0,0 +1,5 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div``;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
new file mode 100644
index 000000000..8854b06cd
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import get from 'lodash/get';
+import { useDispatch, useSelector } from 'react-redux';
+import CodeEditor from 'components/CodeEditor';
+import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import { useTheme } from 'providers/Theme';
+import StyledWrapper from './StyledWrapper';
+
+const Tests = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const tests = get(folder, 'root.request.tests', '');
+
+ const { displayedTheme } = useTheme();
+ const preferences = useSelector((state) => state.app.preferences);
+
+ const onEdit = (value) => {
+ dispatch(
+ updateFolderTests({
+ tests: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+
+ return (
+
+ These tests will run any time a request in this collection is sent.
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Tests;
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Vars/StyledWrapper.js
new file mode 100644
index 000000000..44b01b464
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Vars/StyledWrapper.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ div.title {
+ color: var(--color-tab-inactive);
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/StyledWrapper.js
new file mode 100644
index 000000000..efacc8288
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/StyledWrapper.js
@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(3) {
+ width: 70px;
+ }
+ }
+ }
+
+ .btn-add-var {
+ font-size: 0.8125rem;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ background-color: inherit;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js
new file mode 100644
index 000000000..b0815c018
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js
@@ -0,0 +1,161 @@
+import React from 'react';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import SingleLineEditor from 'components/SingleLineEditor';
+import InfoTip from 'components/InfoTip';
+import StyledWrapper from './StyledWrapper';
+import toast from 'react-hot-toast';
+import { variableNameRegex } from 'utils/common/regex';
+import { addFolderVar, deleteFolderVar, updateFolderVar } from 'providers/ReduxStore/slices/collections/index';
+
+const VarsTable = ({ folder, collection, vars, varType }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+
+ const addVar = () => {
+ dispatch(
+ addFolderVar({
+ collectionUid: collection.uid,
+ folderUid: folder.uid,
+ type: varType
+ })
+ );
+ };
+
+ const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+ const handleVarChange = (e, v, type) => {
+ const _var = cloneDeep(v);
+ switch (type) {
+ case 'name': {
+ const value = e.target.value;
+
+ if (variableNameRegex.test(value) === false) {
+ toast.error(
+ 'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."'
+ );
+ return;
+ }
+
+ _var.name = value;
+ break;
+ }
+ case 'value': {
+ _var.value = e.target.value;
+ break;
+ }
+ case 'enabled': {
+ _var.enabled = e.target.checked;
+ break;
+ }
+ }
+ dispatch(
+ updateFolderVar({
+ type: varType,
+ var: _var,
+ folderUid: folder.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleRemoveVar = (_var) => {
+ dispatch(
+ deleteFolderVar({
+ type: varType,
+ varUid: _var.uid,
+ folderUid: folder.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ return (
+
+
+
+ + Add
+
+
+ );
+};
+export default VarsTable;
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/index.js
new file mode 100644
index 000000000..8f9cab4d2
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Vars/index.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import get from 'lodash/get';
+import VarsTable from './VarsTable';
+import StyledWrapper from './StyledWrapper';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+
+const Vars = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const requestVars = get(folder, 'root.request.vars.req', []);
+ const responseVars = get(folder, 'root.request.vars.res', []);
+ const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+ return (
+
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Vars;
diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js
new file mode 100644
index 000000000..621ae6815
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/index.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import classnames from 'classnames';
+import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
+import { useDispatch } from 'react-redux';
+import Headers from './Headers';
+import Script from './Script';
+import Tests from './Tests';
+import StyledWrapper from './StyledWrapper';
+import Vars from './Vars';
+import Documentation from './Documentation';
+import Auth from './Auth';
+import DotIcon from 'components/Icons/Dot';
+import get from 'lodash/get';
+
+const ContentIndicator = () => {
+ return (
+
+
+
+ );
+};
+
+const FolderSettings = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ let tab = 'headers';
+ const { folderLevelSettingsSelectedTab } = collection;
+ if (folderLevelSettingsSelectedTab?.[folder?.uid]) {
+ tab = folderLevelSettingsSelectedTab[folder?.uid];
+ }
+
+ const folderRoot = folder?.root;
+ const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
+ const hasTests = folderRoot?.request?.tests;
+
+ const headers = folderRoot?.request?.headers || [];
+ const activeHeadersCount = headers.filter((header) => header.enabled).length;
+
+ const requestVars = folderRoot?.request?.vars?.req || [];
+ const responseVars = folderRoot?.request?.vars?.res || [];
+ const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
+
+ const auth = get(folderRoot, 'request.auth.mode');
+ const hasAuth = auth && auth !== 'none';
+
+ const setTab = (tab) => {
+ dispatch(
+ updatedFolderSettingsSelectedTab({
+ collectionUid: collection?.uid,
+ folderUid: folder?.uid,
+ tab
+ })
+ );
+ };
+
+ const getTabPanel = (tab) => {
+ switch (tab) {
+ case 'headers': {
+ return ;
+ }
+ case 'script': {
+ return ;
+ }
+ case 'test': {
+ return ;
+ }
+ case 'vars': {
+ return ;
+ }
+ case 'auth': {
+ return ;
+ }
+ case 'docs': {
+ return ;
+ }
+ }
+ };
+
+ const getTabClassname = (tabName) => {
+ return classnames(`tab select-none ${tabName}`, {
+ active: tabName === tab
+ });
+ };
+
+ return (
+
+
+
+
setTab('headers')}>
+ Headers
+ {activeHeadersCount > 0 && {activeHeadersCount} }
+
+
setTab('script')}>
+ Script
+ {hasScripts && }
+
+
setTab('test')}>
+ Test
+ {hasTests && }
+
+
setTab('vars')}>
+ Vars
+ {activeVarsCount > 0 && {activeVarsCount} }
+
+
setTab('auth')}>
+ Auth
+ {hasAuth && }
+
+
setTab('docs')}>
+ Docs
+
+
+
+
+
+ );
+};
+
+export default FolderSettings;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js
new file mode 100644
index 000000000..7504d5c1e
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/StyledWrapper.js
@@ -0,0 +1,18 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .current-environment {
+ }
+ .environment-active {
+ padding: 0.3rem 0.4rem;
+ color: ${(props) => props.theme.colors.text.yellow};
+ border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
+ }
+ .environment-selector {
+ .active: {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js
new file mode 100644
index 000000000..b0042bcbf
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js
@@ -0,0 +1,100 @@
+import React, { useRef, forwardRef, useState } from 'react';
+import find from 'lodash/find';
+import Dropdown from 'components/Dropdown';
+import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
+import EnvironmentSettings from '../EnvironmentSettings';
+import toast from 'react-hot-toast';
+import { useDispatch, useSelector } from 'react-redux';
+import StyledWrapper from './StyledWrapper';
+import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import ToolHint from 'components/ToolHint/index';
+
+const EnvironmentSelector = () => {
+ const dispatch = useDispatch();
+ const dropdownTippyRef = useRef();
+ const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
+ const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
+ const [openSettingsModal, setOpenSettingsModal] = useState(false);
+ const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+
+
+ {
+ activeEnvironment ? {activeEnvironment?.name}
: null
+ }
+
+
+ );
+ });
+
+ const handleSettingsIconClick = () => {
+ setOpenSettingsModal(true);
+ };
+
+ const handleModalClose = () => {
+ setOpenSettingsModal(false);
+ };
+
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
+ const onSelect = (environment) => {
+ dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
+ .then(() => {
+ if (environment) {
+ toast.success(`Environment changed to ${environment.name}`);
+ } else {
+ toast.success(`No Environments are active now`);
+ }
+ })
+ .catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
+ };
+
+ return (
+
+
+
} placement="bottom-end" transparent={true}>
+
Global Environments
+ {globalEnvironments && globalEnvironments.length
+ ? globalEnvironments.map((e) => (
+
{
+ onSelect(e);
+ dropdownTippyRef.current.hide();
+ }}
+ >
+ {e.name}
+
+ ))
+ : null}
+
{
+ dropdownTippyRef.current.hide();
+ onSelect(null);
+ }}
+ >
+
+ No Environment
+
+
{
+ handleSettingsIconClick();
+ dropdownTippyRef.current.hide();
+ }}>
+
+
+
+
Configure
+
+
+
+ {openSettingsModal && }
+
+ );
+};
+
+export default EnvironmentSelector;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js
new file mode 100644
index 000000000..ed9ea40b0
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CopyEnvironment/index.js
@@ -0,0 +1,78 @@
+import Modal from 'components/Modal/index';
+import Portal from 'components/Portal/index';
+import { useFormik } from 'formik';
+import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { useEffect, useRef } from 'react';
+import toast from 'react-hot-toast';
+import { useDispatch } from 'react-redux';
+import * as Yup from 'yup';
+
+const CopyEnvironment = ({ environment, onClose }) => {
+ const dispatch = useDispatch();
+ const inputRef = useRef();
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ name: environment.name + ' - Copy'
+ },
+ validationSchema: Yup.object({
+ name: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(50, 'must be 50 characters or less')
+ .required('name is required')
+ }),
+ onSubmit: (values) => {
+ dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
+ .then(() => {
+ toast.success('Global environment created!');
+ onClose();
+ })
+ .catch((error) => {
+ toast.error('An error occurred while created the environment');
+ console.error(error);
+ });
+ }
+ });
+
+ useEffect(() => {
+ if (inputRef && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [inputRef]);
+
+ const onSubmit = () => {
+ formik.handleSubmit();
+ };
+
+ return (
+
+
+ e.preventDefault()}>
+
+
+ New Environment Name
+
+
+ {formik.touched.name && formik.errors.name ? (
+
{formik.errors.name}
+ ) : null}
+
+
+
+
+ );
+};
+
+export default CopyEnvironment;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js
new file mode 100644
index 000000000..d9eb83191
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js
@@ -0,0 +1,96 @@
+import React, { useEffect, useRef } from 'react';
+import toast from 'react-hot-toast';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import { useDispatch, useSelector } from 'react-redux';
+import Portal from 'components/Portal';
+import Modal from 'components/Modal';
+import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { validateName, validateNameError } from 'utils/common/regex';
+
+const CreateEnvironment = ({ onClose }) => {
+ const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
+
+ const validateEnvironmentName = (name) => {
+ const trimmedName = name?.toLowerCase().trim();
+ return globalEnvs.every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
+ };
+
+ const dispatch = useDispatch();
+ const inputRef = useRef();
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ name: ''
+ },
+ validationSchema: Yup.object({
+ name: Yup.string()
+ .min(1, 'Must be at least 1 character')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .required('Name is required')
+ .test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
+ }),
+ onSubmit: (values) => {
+ dispatch(addGlobalEnvironment({ name: values.name }))
+ .then(() => {
+ toast.success('Global environment created!');
+ onClose();
+ })
+ .catch(() => toast.error('An error occurred while creating the environment'));
+ }
+ });
+
+ useEffect(() => {
+ if (inputRef && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [inputRef]);
+
+ const onSubmit = () => {
+ formik.handleSubmit();
+ };
+
+ return (
+
+
+ e.preventDefault()}>
+
+
+ Environment Name
+
+
+
+
+ {formik.touched.name && formik.errors.name ? (
+
{formik.errors.name}
+ ) : null}
+
+
+
+
+ );
+};
+
+export default CreateEnvironment;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js
new file mode 100644
index 000000000..48b874214
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js
@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ button.submit {
+ color: white;
+ background-color: var(--color-background-danger) !important;
+ border: inherit !important;
+
+ &:hover {
+ border: inherit !important;
+ }
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js
new file mode 100644
index 000000000..2edab3be8
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/DeleteEnvironment/index.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import Portal from 'components/Portal/index';
+import toast from 'react-hot-toast';
+import Modal from 'components/Modal/index';
+import { useDispatch } from 'react-redux';
+import StyledWrapper from './StyledWrapper';
+import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+
+const DeleteEnvironment = ({ onClose, environment }) => {
+ const dispatch = useDispatch();
+ const onConfirm = () => {
+ dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
+ .then(() => {
+ toast.success('Global Environment deleted successfully');
+ onClose();
+ })
+ .catch(() => toast.error('An error occurred while deleting the environment'));
+ };
+
+ return (
+
+
+
+ Are you sure you want to delete {environment.name} ?
+
+
+
+ );
+};
+
+export default DeleteEnvironment;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js
new file mode 100644
index 000000000..715bf9e75
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import { IconAlertTriangle } from '@tabler/icons';
+import Modal from 'components/Modal';
+import { createPortal } from 'react-dom';
+
+const ConfirmSwitchEnv = ({ onCancel }) => {
+ return createPortal(
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ hideFooter={true}
+ >
+
+
+
Hold on..
+
+ You have unsaved changes in this environment.
+
+
+ ,
+ document.body
+ );
+};
+
+export default ConfirmSwitchEnv;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
new file mode 100644
index 000000000..5f4e34d8f
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
@@ -0,0 +1,66 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
+ padding: 4px 10px;
+
+ &:nth-child(1),
+ &:nth-child(4) {
+ width: 70px;
+ }
+ &:nth-child(5) {
+ width: 40px;
+ }
+
+ &:nth-child(2) {
+ width: 25%;
+ }
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ thead td {
+ padding: 6px 10px;
+ }
+ }
+
+ .btn-add-param {
+ font-size: 0.8125rem;
+ }
+
+ .tooltip-mod {
+ font-size: 11px !important;
+ width: 150px !important;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ background-color: transparent;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
new file mode 100644
index 000000000..10ab9fba3
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -0,0 +1,199 @@
+import React, { useRef, useEffect } from 'react';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash, IconAlertCircle } from '@tabler/icons';
+import { useTheme } from 'providers/Theme';
+import { useDispatch } from 'react-redux';
+import SingleLineEditor from 'components/SingleLineEditor';
+import StyledWrapper from './StyledWrapper';
+import { uuid } from 'utils/common';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import { variableNameRegex } from 'utils/common/regex';
+import toast from 'react-hot-toast';
+import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { Tooltip } from 'react-tooltip';
+
+const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const addButtonRef = useRef(null);
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: environment.variables || [],
+ validationSchema: Yup.array().of(
+ Yup.object({
+ enabled: Yup.boolean(),
+ name: Yup.string()
+ .required('Name cannot be empty')
+ .matches(
+ variableNameRegex,
+ 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
+ )
+ .trim(),
+ secret: Yup.boolean(),
+ type: Yup.string(),
+ uid: Yup.string(),
+ value: Yup.string().trim().nullable()
+ })
+ ),
+ onSubmit: (values) => {
+ if (!formik.dirty) {
+ toast.error('Nothing to save');
+ return;
+ }
+
+ dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(values) }))
+ .then(() => {
+ toast.success('Changes saved successfully');
+ formik.resetForm({ values });
+ setIsModified(false);
+ })
+ .catch((error) => {
+ console.error(error);
+ toast.error('An error occurred while saving the changes')
+ });
+ }
+ });
+
+ // Effect to track modifications.
+ React.useEffect(() => {
+ setIsModified(formik.dirty);
+ }, [formik.dirty]);
+
+ const ErrorMessage = ({ name }) => {
+ const meta = formik.getFieldMeta(name);
+ const id = uuid();
+ if (!meta.error || !meta.touched) {
+ return null;
+ }
+ return (
+
+
+
+
+ );
+ };
+
+ const addVariable = () => {
+ const newVariable = {
+ uid: uuid(),
+ name: '',
+ value: '',
+ type: 'text',
+ secret: false,
+ enabled: true
+ };
+ formik.setFieldValue(formik.values.length, newVariable, false);
+ };
+
+ const handleRemoveVar = (id) => {
+ formik.setValues(formik.values.filter((variable) => variable.uid !== id));
+ };
+
+ useEffect(() => {
+ if (formik.dirty) {
+ // Smooth scrolling to the changed parameter is temporarily disabled
+ // due to UX issues when editing the first row in a long list of environment variables.
+ // addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [formik.values, formik.dirty]);
+
+ const handleReset = () => {
+ formik.resetForm({ originalEnvironmentVariables });
+ };
+
+ return (
+
+
+
+
+
+ + Add Variable
+
+
+
+
+
+
+ Save
+
+
+ Reset
+
+
+
+ );
+};
+export default EnvironmentVariables;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
new file mode 100644
index 000000000..e4dba0e9c
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
@@ -0,0 +1,46 @@
+import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
+import { useState } from 'react';
+import CopyEnvironment from '../../CopyEnvironment';
+import DeleteEnvironment from '../../DeleteEnvironment';
+import RenameEnvironment from '../../RenameEnvironment';
+import EnvironmentVariables from './EnvironmentVariables';
+
+const EnvironmentDetails = ({ environment, setIsModified }) => {
+ const [openEditModal, setOpenEditModal] = useState(false);
+ const [openDeleteModal, setOpenDeleteModal] = useState(false);
+ const [openCopyModal, setOpenCopyModal] = useState(false);
+
+ return (
+
+ {openEditModal && (
+
setOpenEditModal(false)} environment={environment} />
+ )}
+ {openDeleteModal && (
+ setOpenDeleteModal(false)}
+ environment={environment}
+ />
+ )}
+ {openCopyModal && (
+ setOpenCopyModal(false)} environment={environment} />
+ )}
+
+
+
+ {environment.name}
+
+
+ setOpenEditModal(true)} />
+ setOpenCopyModal(true)} />
+ setOpenDeleteModal(true)} />
+
+
+
+
+
+
+
+ );
+};
+
+export default EnvironmentDetails;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
new file mode 100644
index 000000000..dd9761532
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
@@ -0,0 +1,62 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ margin-inline: -1rem;
+ margin-block: -1.5rem;
+
+ background-color: ${(props) => props.theme.collection.environment.settings.bg};
+
+ .environments-sidebar {
+ background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
+ border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
+ min-height: 400px;
+ height: 100%;
+ max-height: 85vh;
+ overflow-y: auto;
+ }
+
+ .environment-item {
+ min-width: 150px;
+ display: block;
+ position: relative;
+ cursor: pointer;
+ padding: 8px 10px;
+ border-left: solid 2px transparent;
+ text-decoration: none;
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: none;
+ background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
+ }
+ }
+
+ .active {
+ background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
+ border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
+ &:hover {
+ background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
+ }
+ }
+
+ .btn-create-environment,
+ .btn-import-environment {
+ padding: 8px 10px;
+ cursor: pointer;
+ border-bottom: none;
+ color: ${(props) => props.theme.textLink};
+
+ span:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .btn-import-environment {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js
new file mode 100644
index 000000000..d04edd838
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/index.js
@@ -0,0 +1,152 @@
+import React, { useEffect, useState } from 'react';
+import usePrevious from 'hooks/usePrevious';
+import EnvironmentDetails from './EnvironmentDetails';
+import CreateEnvironment from '../CreateEnvironment';
+import { IconDownload, IconShieldLock } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+import ConfirmSwitchEnv from './ConfirmSwitchEnv';
+import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
+import ImportEnvironment from '../ImportEnvironment';
+import { isEqual } from 'lodash';
+import ToolHint from 'components/ToolHint/index';
+
+const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
+ const [openCreateModal, setOpenCreateModal] = useState(false);
+ const [openImportModal, setOpenImportModal] = useState(false);
+ const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
+
+ const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
+ const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
+
+ const envUids = environments ? environments.map((env) => env.uid) : [];
+ const prevEnvUids = usePrevious(envUids);
+
+ useEffect(() => {
+ if (!environments?.length) {
+ setSelectedEnvironment(null);
+ setOriginalEnvironmentVariables([]);
+ return;
+ }
+
+ if (selectedEnvironment) {
+ const _selectedEnvironment = environments?.find(env => env?.uid === selectedEnvironment?.uid);
+ const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
+ if (hasSelectedEnvironmentChanged) {
+ setSelectedEnvironment(_selectedEnvironment);
+ }
+ setOriginalEnvironmentVariables(selectedEnvironment.variables);
+ return;
+ }
+
+ const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
+
+ setSelectedEnvironment(environment);
+ setOriginalEnvironmentVariables(environment?.variables || []);
+ }, [environments, activeEnvironmentUid]);
+
+
+ useEffect(() => {
+ if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
+ const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
+ if (newEnv) {
+ setSelectedEnvironment(newEnv);
+ }
+ }
+
+ if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
+ setSelectedEnvironment(environments && environments.length ? environments[0] : null);
+ }
+ }, [envUids, environments, prevEnvUids]);
+
+ const handleEnvironmentClick = (env) => {
+ if (!isModified) {
+ setSelectedEnvironment(env);
+ } else {
+ setSwitchEnvConfirmClose(true);
+ }
+ };
+
+ if (!selectedEnvironment) {
+ return null;
+ }
+
+ const handleCreateEnvClick = () => {
+ if (!isModified) {
+ setOpenCreateModal(true);
+ } else {
+ setSwitchEnvConfirmClose(true);
+ }
+ };
+
+ const handleImportClick = () => {
+ if (!isModified) {
+ setOpenImportModal(true);
+ } else {
+ setSwitchEnvConfirmClose(true);
+ }
+ };
+
+ const handleSecretsClick = () => {
+ setOpenManageSecretsModal(true);
+ };
+
+ const handleConfirmSwitch = (saveChanges) => {
+ if (!saveChanges) {
+ setSwitchEnvConfirmClose(false);
+ }
+ };
+
+ return (
+
+ {openCreateModal && setOpenCreateModal(false)} />}
+ {openImportModal && setOpenImportModal(false)} />}
+ {openManageSecretsModal && setOpenManageSecretsModal(false)} />}
+
+
+
+ {switchEnvConfirmClose && (
+
+ handleConfirmSwitch(false)} />
+
+ )}
+
+ {environments &&
+ environments.length &&
+ environments.map((env) => (
+
+ handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle click
+ >
+ {env.name}
+
+
+ ))}
+
handleCreateEnvClick()}>
+ + Create
+
+
+
+
handleImportClick()}>
+
+ Import
+
+
handleSecretsClick()}>
+
+ Managing Secrets
+
+
+
+
+
+
+
+ );
+};
+
+export default EnvironmentList;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js
new file mode 100644
index 000000000..55d67946b
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import Portal from 'components/Portal';
+import Modal from 'components/Modal';
+import toast from 'react-hot-toast';
+import { useDispatch } from 'react-redux';
+import importPostmanEnvironment from 'utils/importers/postman-environment';
+import { toastError } from 'utils/common/error';
+import { IconDatabaseImport } from '@tabler/icons';
+import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { uuid } from 'utils/common/index';
+
+const ImportEnvironment = ({ onClose }) => {
+ const dispatch = useDispatch();
+
+ const handleImportPostmanEnvironment = () => {
+ importPostmanEnvironment()
+ .then((environments) => {
+ environments
+ .filter((env) =>
+ env.name && env.name !== 'undefined'
+ ? true
+ : () => {
+ toast.error('Failed to import environment: env has no name');
+ return false;
+ }
+ )
+ .map((environment) => {
+ let variables = environment?.variables?.map(v => ({
+ ...v,
+ uid: uuid(),
+ type: 'text'
+ }));
+ dispatch(addGlobalEnvironment({ name: environment.name, variables }))
+ .then(() => {
+ toast.success('Global Environment imported successfully');
+ })
+ .catch((error) => {
+ toast.error('An error occurred while importing the environment');
+ console.error(error);
+ });
+ });
+ })
+ .then(() => {
+ onClose();
+ })
+ .catch((err) => toastError(err, 'Postman Import environment failed'));
+ };
+
+ return (
+
+
+
+
+ Import your Postman environments
+
+
+
+ );
+};
+
+export default ImportEnvironment;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js
new file mode 100644
index 000000000..581abd27c
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js
@@ -0,0 +1,92 @@
+import React, { useEffect, useRef } from 'react';
+import Portal from 'components/Portal/index';
+import Modal from 'components/Modal/index';
+import toast from 'react-hot-toast';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import { useDispatch } from 'react-redux';
+import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { validateName, validateNameError } from 'utils/common/regex';
+
+const RenameEnvironment = ({ onClose, environment }) => {
+ const dispatch = useDispatch();
+ const inputRef = useRef();
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ name: environment.name
+ },
+ validationSchema: Yup.object({
+ name: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .required('name is required')
+ }),
+ onSubmit: (values) => {
+ if (values.name === environment.name) {
+ return;
+ }
+ dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
+ .then(() => {
+ toast.success('Environment renamed successfully');
+ onClose();
+ })
+ .catch((error) => {
+ toast.error('An error occurred while renaming the environment');
+ console.error(error);
+ });
+ }
+ });
+
+ useEffect(() => {
+ if (inputRef && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [inputRef]);
+
+ const onSubmit = () => {
+ formik.handleSubmit();
+ };
+
+ return (
+
+
+ e.preventDefault()}>
+
+
+ Environment Name
+
+
+ {formik.touched.name && formik.errors.name ? (
+
{formik.errors.name}
+ ) : null}
+
+
+
+
+ );
+};
+
+export default RenameEnvironment;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js
new file mode 100644
index 000000000..2dfad0cfe
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/StyledWrapper.js
@@ -0,0 +1,13 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ button.btn-create-environment {
+ &:hover {
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js
new file mode 100644
index 000000000..551a4ec58
--- /dev/null
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/index.js
@@ -0,0 +1,78 @@
+import Modal from 'components/Modal/index';
+import React, { useState } from 'react';
+import CreateEnvironment from './CreateEnvironment';
+import EnvironmentList from './EnvironmentList';
+import StyledWrapper from './StyledWrapper';
+import { IconFileAlert } from '@tabler/icons';
+import ImportEnvironment from './ImportEnvironment/index';
+
+export const SharedButton = ({ children, className, onClick }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const DefaultTab = ({ setTab }) => {
+ return (
+
+
+
No Global Environments found
+
+ setTab('create')}>
+ Create Global Environment
+
+
+ Or
+
+ setTab('import')}>
+ Import Environment
+
+
+
+ );
+};
+
+const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
+ const [isModified, setIsModified] = useState(false);
+ const environments = globalEnvironments;
+ const [selectedEnvironment, setSelectedEnvironment] = useState(null);
+ const [tab, setTab] = useState('default');
+ if (!environments || !environments.length) {
+ return (
+
+
+ {tab === 'create' ? (
+ setTab('default')} />
+ ) : tab === 'import' ? (
+ setTab('default')} />
+ ) : (
+ <>>
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default EnvironmentSettings;
diff --git a/packages/bruno-app/src/components/Help/StyledWrapper.js b/packages/bruno-app/src/components/Help/StyledWrapper.js
new file mode 100644
index 000000000..f4a69fe40
--- /dev/null
+++ b/packages/bruno-app/src/components/Help/StyledWrapper.js
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ font-weight: 400;
+ font-size: 0.75rem;
+ background-color: ${props => props.theme.infoTip.bg};
+ border: 1px solid ${props => props.theme.infoTip.border};
+ box-shadow: ${props => props.theme.infoTip.boxShadow};
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/Help/index.js b/packages/bruno-app/src/components/Help/index.js
new file mode 100644
index 000000000..6d3f40f87
--- /dev/null
+++ b/packages/bruno-app/src/components/Help/index.js
@@ -0,0 +1,40 @@
+/**
+ * The InfoTip components needs to be nuked
+ * This component will be the future replacement
+ * We should allow icon and placement props to be passed in
+ */
+
+import React, { useState } from 'react';
+import HelpIcon from 'components/Icons/Help';
+import StyledWrapper from './StyledWrapper';
+
+const Help = ({ children, width = 200 }) => {
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ return (
+
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+
+
+ {showTooltip && (
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default Help;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/Dot/index.js b/packages/bruno-app/src/components/Icons/Dot/index.js
new file mode 100644
index 000000000..a69a375b1
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/Dot/index.js
@@ -0,0 +1,16 @@
+import React from 'react';
+
+const DotIcon = ({ width }) => {
+ return (
+
+
+
+
+ );
+};
+
+export default DotIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/Help/index.js b/packages/bruno-app/src/components/Icons/Help/index.js
new file mode 100644
index 000000000..95c8710af
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/Help/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+const HelpIcon = ({ size = 14 }) => {
+ return (
+
+
+
+
+ )
+}
+
+export default HelpIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js b/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
new file mode 100644
index 000000000..b472b3d8c
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
@@ -0,0 +1,104 @@
+const OpenApiLogo = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OpenApiLogo;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Tooltip/index.js b/packages/bruno-app/src/components/InfoTip/index.js
similarity index 79%
rename from packages/bruno-app/src/components/Tooltip/index.js
rename to packages/bruno-app/src/components/InfoTip/index.js
index d5ab5c41d..2af6034ed 100644
--- a/packages/bruno-app/src/components/Tooltip/index.js
+++ b/packages/bruno-app/src/components/InfoTip/index.js
@@ -1,26 +1,25 @@
import React from 'react';
-import { Tooltip as ReactTooltip } from 'react-tooltip';
+import { Tooltip as ReactInfoTip } from 'react-tooltip';
-const Tooltip = ({ text, tooltipId }) => {
+const InfoTip = ({ html: _ignored, infotipId, ...props }) => {
return (
<>
-
+
>
);
};
-export default Tooltip;
+export default InfoTip;
diff --git a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
index 65cb9c23b..a7a174a69 100644
--- a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
+++ b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
@@ -2,7 +2,6 @@ import styled from 'styled-components';
const StyledMarkdownBodyWrapper = styled.div`
background: transparent;
- height: inherit;
.markdown-body {
background: transparent;
overflow-y: auto;
@@ -10,21 +9,20 @@ const StyledMarkdownBodyWrapper = styled.div`
box-sizing: border-box;
height: 100%;
margin: 0 auto;
- padding-top: 0.5rem;
font-size: 0.875rem;
h1 {
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
- font-size: 1.3;
+ font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
- font-size: 1.2;
+ font-size: 1.2em;
border-bottom: 1px solid var(--color-border-muted);
}
@@ -56,7 +54,7 @@ const StyledMarkdownBodyWrapper = styled.div`
height: 1px;
padding: 0;
margin: 24px 0;
- background-color: var(--color-border-default);
+ background-color: var(--color-sidebar-collection-item-active-indent-border);
border: 0;
}
@@ -70,6 +68,7 @@ const StyledMarkdownBodyWrapper = styled.div`
pre {
background: ${(props) => props.theme.sidebar.bg};
+ color: ${(props) => props.theme.text};
}
table {
@@ -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/MarkDown/index.jsx b/packages/bruno-app/src/components/MarkDown/index.jsx
index 80f28cacf..e6582a8b6 100644
--- a/packages/bruno-app/src/components/MarkDown/index.jsx
+++ b/packages/bruno-app/src/components/MarkDown/index.jsx
@@ -1,15 +1,36 @@
import MarkdownIt from 'markdown-it';
+import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
-import * as React from 'react';
+import React from 'react';
+import { isValidUrl } from 'utils/url/index';
-const md = new MarkdownIt();
+const Markdown = ({ collectionPath, onDoubleClick, content }) => {
+ const markdownItOptions = {
+ replaceLink: function (link, env) {
+ return link.replace(/^\./, collectionPath);
+ }
+ };
+
+ const handleOnClick = (event) => {
+ const target = event.target;
+ if (target.tagName === 'A') {
+ event.preventDefault();
+ const href = target.getAttribute('href');
+ if (href && isValidUrl(href)) {
+ window.open(href, '_blank');
+ return;
+ }
+ }
+ };
-const Markdown = ({ onDoubleClick, content }) => {
const handleOnDoubleClick = (event) => {
- if (event?.detail === 2) {
+ if (event.detail === 2) {
onDoubleClick();
}
};
+
+ const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
+
const htmlFromMarkdown = md.render(content || '');
return (
@@ -17,7 +38,8 @@ const Markdown = ({ onDoubleClick, content }) => {
);
diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js
index 49fcccf02..99bf1f89d 100644
--- a/packages/bruno-app/src/components/Modal/index.js
+++ b/packages/bruno-app/src/components/Modal/index.js
@@ -1,10 +1,14 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
+import useFocusTrap from 'hooks/useFocusTrap';
-const ModalHeader = ({ title, handleCancel, customHeader }) => (
+const ESC_KEY_CODE = 27;
+const ENTER_KEY_CODE = 13;
+
+const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
{customHeader ? customHeader : <>{title ?
{title}
: null}>}
- {handleCancel ? (
+ {handleCancel && !hideClose ? (
handleCancel() : null}>
×
@@ -12,7 +16,7 @@ const ModalHeader = ({ title, handleCancel, customHeader }) => (
);
-const ModalContent = ({ children }) => {children}
;
+const ModalContent = ({ children }) => {children}
;
const ModalFooter = ({
confirmText,
@@ -58,37 +62,48 @@ const Modal = ({
confirmText,
cancelText,
handleCancel,
- handleConfirm,
+ handleConfirm = () => {},
children,
confirmDisabled,
hideCancel,
hideFooter,
+ hideClose,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500
}) => {
+ const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
- const escFunction = (event) => {
- const escKeyCode = 27;
- if (event.keyCode === escKeyCode) {
- closeModal({ type: 'esc' });
+
+ const handleKeydown = (event) => {
+ const { keyCode, shiftKey, ctrlKey, altKey, metaKey } = event;
+ switch (keyCode) {
+ case ESC_KEY_CODE: {
+ if (disableEscapeKey) return;
+ return closeModal({ type: 'esc' });
+ }
+ case ENTER_KEY_CODE: {
+ if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
+ return handleConfirm();
+ }
+ }
}
};
+ useFocusTrap(modalRef);
+
const closeModal = (args) => {
setIsClosing(true);
setTimeout(() => handleCancel(args), closeModalFadeTimeout);
};
useEffect(() => {
- if (disableEscapeKey) return;
- document.addEventListener('keydown', escFunction, false);
-
+ document.addEventListener('keydown', handleKeydown, false);
return () => {
- document.removeEventListener('keydown', escFunction, false);
+ document.removeEventListener('keydown', handleKeydown);
};
- }, [disableEscapeKey, document]);
+ }, [disableEscapeKey, document, handleConfirm]);
let classes = 'bruno-modal';
if (isClosing) {
@@ -99,8 +114,19 @@ const Modal = ({
}
return (
onClick(e) : null}>
-
-
closeModal({ type: 'icon' })} customHeader={customHeader} />
+
+
closeModal({ type: 'icon' })}
+ customHeader={customHeader}
+ />
{children}
props.theme.text};
+ padding-left: 0;
+ opacity: 0.5;
+ }
+
+ .CodeMirror-scroll {
+ overflow: visible !important;
position: relative;
- display: contents;
+ display: block;
margin: 0px;
padding: 0px;
}
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index efcd89f45..1a6709813 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -5,7 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@@ -24,13 +24,16 @@ class MultiLineEditor extends Component {
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
+ const variables = getAllVariables(this.props.collection, this.props.item);
+
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
+ placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
- variables: getAllVariables(this.props.collection)
+ variables
},
scrollbarStyle: null,
tabindex: 0,
@@ -85,7 +88,7 @@ class MultiLineEditor extends Component {
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
- this.addOverlay();
+ this.addOverlay(variables);
}
_onEdit = () => {
@@ -103,10 +106,10 @@ class MultiLineEditor extends Component {
// event loop.
this.ignoreChangeEvent = true;
- let variables = getAllVariables(this.props.collection);
+ let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
- this.addOverlay();
+ this.addOverlay(variables);
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
@@ -125,11 +128,9 @@ class MultiLineEditor extends Component {
this.editor.getWrapperElement().remove();
}
- addOverlay = () => {
- let variables = getAllVariables(this.props.collection);
+ addOverlay = (variables) => {
this.variables = variables;
-
- defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
+ defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};
diff --git a/packages/bruno-app/src/components/Navbar/StyledWrapper.js b/packages/bruno-app/src/components/Navbar/StyledWrapper.js
deleted file mode 100644
index 8336ce672..000000000
--- a/packages/bruno-app/src/components/Navbar/StyledWrapper.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- .collection-dropdown {
- color: rgb(110 110 110);
-
- &:hover {
- color: inherit;
- }
-
- .tippy-box {
- top: -0.5rem;
- position: relative;
- user-select: none;
- }
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Navbar/index.js b/packages/bruno-app/src/components/Navbar/index.js
deleted file mode 100644
index 7f723b5ce..000000000
--- a/packages/bruno-app/src/components/Navbar/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import React, { useState, forwardRef, useRef } from 'react';
-import Dropdown from '../Dropdown';
-import { faCaretDown } from '@fortawesome/free-solid-svg-icons';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { IconBox, IconSearch, IconDots } from '@tabler/icons';
-import StyledWrapper from './StyledWrapper';
-
-const Navbar = () => {
- const [modalOpen, setModalOpen] = useState(false);
-
- const menuDropdownTippyRef = useRef();
- const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
- const MenuIcon = forwardRef((props, ref) => {
- return (
-
-
-
- );
- });
-
- return (
-
-
- Collections
- {/* */}
-
-
-
} placement="bottom-start">
-
{
- menuDropdownTippyRef.current.hide();
- setModalOpen(true);
- }}
- >
- Create Collection
-
-
{
- menuDropdownTippyRef.current.hide();
- }}
- >
- Import Collection
-
-
{
- menuDropdownTippyRef.current.hide();
- }}
- >
- Settings
-
-
-
-
- );
-};
-
-export default Navbar;
diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js
index 15a055c76..d11a6254f 100644
--- a/packages/bruno-app/src/components/Notifications/index.js
+++ b/packages/bruno-app/src/components/Notifications/index.js
@@ -3,6 +3,7 @@ import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
import { useEffect } from 'react';
+import { useApp } from 'providers/App';
import {
fetchNotifications,
markAllNotificationsAsRead,
@@ -10,11 +11,14 @@ import {
} from 'providers/ReduxStore/slices/notifications';
import { useDispatch, useSelector } from 'react-redux';
import { humanizeDate, relativeDate } from 'utils/common';
+import ToolHint from 'components/ToolHint';
+import DOMPurify from 'dompurify';
const PAGE_SIZE = 5;
const Notifications = () => {
const dispatch = useDispatch();
+ const { version } = useApp();
const notifications = useSelector((state) => state.notifications.notifications);
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
@@ -27,7 +31,9 @@ const Notifications = () => {
const unreadNotifications = notifications.filter((notification) => !notification.read);
useEffect(() => {
- dispatch(fetchNotifications());
+ dispatch(fetchNotifications({
+ currentVersion: version
+ }));
}, []);
useEffect(() => {
@@ -63,6 +69,13 @@ const Notifications = () => {
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
};
+ const getSanitizedDescription = (description) => {
+ return DOMPurify.sanitize(encodeURIComponent(description), {
+ ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+ ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
+ });
+ };
+
const modalCustomHeader = (
) : (
diff --git a/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js b/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js
new file mode 100644
index 000000000..326e9f8da
--- /dev/null
+++ b/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js
@@ -0,0 +1,39 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ width: 100%;
+ .path-display {
+ background: ${(props) => props.theme.requestTabPanel.url.bg};
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-size: 0.8125rem;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+
+ .icon-column {
+ padding-right: 8px;
+ align-items: flex-start;
+ padding-top: 2px;
+ }
+
+ .path-container {
+ flex-wrap: wrap;
+ }
+
+ .path-segment {
+ white-space: nowrap;
+ }
+
+
+ .name-container, .file-extension {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+
+ .separator {
+ color: ${(props) => props.theme.text};
+ opacity: 0.6;
+ margin: 0 2px;
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/PathDisplay/index.js b/packages/bruno-app/src/components/PathDisplay/index.js
new file mode 100644
index 000000000..7413d5748
--- /dev/null
+++ b/packages/bruno-app/src/components/PathDisplay/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { IconFolder, IconFile } from '@tabler/icons';
+import path from 'utils/common/path';
+import StyledWrapper from './StyledWrapper';
+
+const PathDisplay = ({
+ baseName = '',
+ iconType = 'file'
+}) => {
+ return (
+
+
+
+
+ {iconType === 'file' ? : }
+
+
+ {baseName}
+
+
+
+
+ );
+};
+
+export default PathDisplay;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Portal/index.js b/packages/bruno-app/src/components/Portal/index.js
index 4d34c0a04..7007c4766 100644
--- a/packages/bruno-app/src/components/Portal/index.js
+++ b/packages/bruno-app/src/components/Portal/index.js
@@ -1,8 +1,6 @@
import { createPortal } from 'react-dom';
-function Portal({ children, wrapperId }) {
- wrapperId = wrapperId || 'bruno-app-body';
-
- return createPortal(children, document.getElementById(wrapperId));
+function Portal({ children }) {
+ return createPortal(children, document.body);
}
export default Portal;
diff --git a/packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Display/Font/StyledWrapper.js
similarity index 100%
rename from packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js
rename to packages/bruno-app/src/components/Preferences/Display/Font/StyledWrapper.js
diff --git a/packages/bruno-app/src/components/Preferences/Display/Font/index.js b/packages/bruno-app/src/components/Preferences/Display/Font/index.js
new file mode 100644
index 000000000..622ea0817
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Display/Font/index.js
@@ -0,0 +1,77 @@
+import React, { useState } from 'react';
+import get from 'lodash/get';
+import { useSelector, useDispatch } from 'react-redux';
+import { savePreferences } from 'providers/ReduxStore/slices/app';
+import StyledWrapper from './StyledWrapper';
+
+const Font = ({ close }) => {
+ const dispatch = useDispatch();
+ const preferences = useSelector((state) => state.app.preferences);
+
+ const [codeFont, setCodeFont] = useState(get(preferences, 'font.codeFont', 'default'));
+ const [codeFontSize, setCodeFontSize] = useState(get(preferences, 'font.codeFontSize', '14'));
+
+ const handleCodeFontChange = (event) => {
+ setCodeFont(event.target.value);
+ };
+
+ const handleCodeFontSizeChange = (event) => {
+ // Restrict to min/max value
+ const clampedSize = Math.max(1, Math.min(event.target.value, 32));
+ setCodeFontSize(clampedSize);
+ };
+
+ const handleSave = () => {
+ dispatch(
+ savePreferences({
+ ...preferences,
+ font: {
+ codeFont,
+ codeFontSize
+ }
+ })
+ ).then(() => {
+ close();
+ });
+ };
+
+ return (
+
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Font;
diff --git a/packages/bruno-app/src/components/Preferences/Theme/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Display/Theme/StyledWrapper.js
similarity index 100%
rename from packages/bruno-app/src/components/Preferences/Theme/StyledWrapper.js
rename to packages/bruno-app/src/components/Preferences/Display/Theme/StyledWrapper.js
diff --git a/packages/bruno-app/src/components/Preferences/Theme/index.js b/packages/bruno-app/src/components/Preferences/Display/Theme/index.js
similarity index 100%
rename from packages/bruno-app/src/components/Preferences/Theme/index.js
rename to packages/bruno-app/src/components/Preferences/Display/Theme/index.js
diff --git a/packages/bruno-app/src/components/Preferences/Display/index.js b/packages/bruno-app/src/components/Preferences/Display/index.js
new file mode 100644
index 000000000..bff2d04b9
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Display/index.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import Font from './Font/index';
+import Theme from './Theme/index';
+
+const Display = ({ close }) => {
+ return (
+
+
+
+ Theme
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Display;
diff --git a/packages/bruno-app/src/components/Preferences/Font/index.js b/packages/bruno-app/src/components/Preferences/Font/index.js
deleted file mode 100644
index 2f27fea8b..000000000
--- a/packages/bruno-app/src/components/Preferences/Font/index.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React, { useState } from 'react';
-import get from 'lodash/get';
-import { useSelector, useDispatch } from 'react-redux';
-import { savePreferences } from 'providers/ReduxStore/slices/app';
-import StyledWrapper from './StyledWrapper';
-
-const Font = ({ close }) => {
- const dispatch = useDispatch();
- const preferences = useSelector((state) => state.app.preferences);
-
- const [codeFont, setCodeFont] = useState(get(preferences, 'font.codeFont', 'default'));
-
- const handleInputChange = (event) => {
- setCodeFont(event.target.value);
- };
-
- const handleSave = () => {
- dispatch(
- savePreferences({
- ...preferences,
- font: {
- codeFont
- }
- })
- ).then(() => {
- close();
- });
- };
-
- return (
-
- Code Editor Font
-
-
-
-
- Save
-
-
-
- );
-};
-
-export default Font;
diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js
index addb72a4d..929eae0ff 100644
--- a/packages/bruno-app/src/components/Preferences/General/index.js
+++ b/packages/bruno-app/src/components/Preferences/General/index.js
@@ -6,8 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
-import path from 'path';
-import slash from 'utils/common/slash';
+import path from 'utils/common/path';
import { IconTrash } from '@tabler/icons';
const General = ({ close }) => {
@@ -47,7 +46,7 @@ const General = ({ close }) => {
filePath: get(preferences, 'request.customCaCertificate.filePath', null)
},
keepDefaultCaCertificates: {
- enabled: get(preferences, 'request.keepDefaultCaCertificates.enabled', false)
+ enabled: get(preferences, 'request.keepDefaultCaCertificates.enabled', true)
},
timeout: preferences.request.timeout,
storeCookies: get(preferences, 'request.storeCookies', true),
@@ -90,7 +89,10 @@ const General = ({ close }) => {
};
const addCaCertificate = (e) => {
- formik.setFieldValue('customCaCertificate.filePath', e.target.files[0]?.path);
+ const filePath = window?.ipcRenderer?.getFilePath(e?.target?.files?.[0]);
+ if (filePath) {
+ formik.setFieldValue('customCaCertificate.filePath', filePath);
+ }
};
const deleteCaCertificate = () => {
@@ -100,7 +102,7 @@ const General = ({ close }) => {
return (
-
+
{
className="mousetrap mr-0"
/>
- Use custom CA Certificate
+ Use Custom CA Certificate
{formik.values.customCaCertificate.filePath ? (
@@ -131,7 +133,7 @@ const General = ({ close }) => {
className={`flex items-center mt-2 pl-6 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
>
- {path.basename(slash(formik.values.customCaCertificate.filePath))}
+ {path.basename(formik.values.customCaCertificate.filePath)}
{
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
htmlFor="keepDefaultCaCertificatesEnabled"
>
- Keep default CA Certificates
+ Keep Default CA Certificates
diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js
new file mode 100644
index 000000000..1a1fe7f01
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js
@@ -0,0 +1,46 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+
+ thead,
+ td {
+ border: 2px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 1rem;
+ user-select: none;
+ }
+
+ td {
+ padding: 4px 8px;
+ }
+
+ thead th {
+ font-weight: 600;
+ padding: 10px;
+ text-align: left;
+ }
+ }
+
+ .table-container {
+ max-height: 400px;
+ overflow-y: scroll;
+ }
+
+ .key-button {
+ display: inline-block;
+ color: ${(props) => props.theme.table.input.color};
+ border-radius: 4px;
+ padding: 1px 5px;
+ font-family: monospace;
+ margin-right: 8px;
+ border: 1px solid #ccc;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/index.js b/packages/bruno-app/src/components/Preferences/Keybindings/index.js
new file mode 100644
index 000000000..d2bc918aa
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Keybindings/index.js
@@ -0,0 +1,45 @@
+import StyledWrapper from './StyledWrapper';
+import React from 'react';
+import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
+import { isMacOS } from 'utils/common/platform';
+
+const Keybindings = ({ close }) => {
+ const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
+
+ return (
+
+
+
+
+
+ Command
+ Keybinding
+
+
+
+ {keyMapping ? (
+ Object.entries(keyMapping).map(([action, { name, keys }], index) => (
+
+ {name}
+
+ {keys.split('+').map((key, i) => (
+
+ {key}
+
+ ))}
+
+
+ ))
+ ) : (
+
+ No key bindings available
+
+ )}
+
+
+
+
+ );
+};
+
+export default Keybindings;
diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js
index 42d06266d..ed5724c7c 100644
--- a/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Preferences/ProxySettings/StyledWrapper.js
@@ -2,7 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.settings-label {
- width: 80px;
+ width: 100px;
}
.textbox {
@@ -20,6 +20,12 @@ const StyledWrapper = styled.div`
outline: none !important;
}
}
+
+ .system-proxy-settings {
+ label {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
index 849421661..e7ac735c7 100644
--- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
+++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
@@ -11,14 +11,17 @@ import { useState } from 'react';
const ProxySettings = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
+ const systemProxyEnvVariables = useSelector((state) => state.app.systemProxyEnvVariables);
+ const { http_proxy, https_proxy, no_proxy } = systemProxyEnvVariables || {};
const dispatch = useDispatch();
+ console.log(preferences);
const proxySchema = Yup.object({
- enabled: Yup.boolean(),
+ mode: Yup.string().oneOf(['off', 'on', 'system']),
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string()
.when('enabled', {
- is: true,
+ is: 'on',
then: (hostname) => hostname.required('Specify the hostname for your proxy.'),
otherwise: (hostname) => hostname.nullable()
})
@@ -31,7 +34,7 @@ const ProxySettings = ({ close }) => {
.transform((_, val) => (val ? Number(val) : null)),
auth: Yup.object()
.when('enabled', {
- is: true,
+ is: 'on',
then: Yup.object({
enabled: Yup.boolean(),
username: Yup.string()
@@ -54,7 +57,7 @@ const ProxySettings = ({ close }) => {
const formik = useFormik({
initialValues: {
- enabled: preferences.proxy.enabled || false,
+ mode: preferences.proxy.mode,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || 0,
@@ -94,7 +97,7 @@ const ProxySettings = ({ close }) => {
useEffect(() => {
formik.setValues({
- enabled: preferences.proxy.enabled || false,
+ mode: preferences.proxy.mode,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || '',
@@ -109,188 +112,256 @@ const ProxySettings = ({ close }) => {
return (
- Global Proxy Settings
-
-
- Enabled
-
-
-
-
+
-
-
-
- Hostname
-
-
- {formik.touched.hostname && formik.errors.hostname ? (
-
{formik.errors.hostname}
- ) : null}
-
-
-
- Port
-
-
- {formik.touched.port && formik.errors.port ? (
-
{formik.errors.port}
- ) : null}
-
-
-
- Auth
-
-
-
-
-
-
- Username
-
-
- {formik.touched.auth?.username && formik.errors.auth?.username ? (
-
{formik.errors.auth.username}
- ) : null}
+ {formik?.values?.mode === 'system' ? (
+
+
+ Below values are sourced from your system environment variables and cannot be directly updated in Bruno.
+ Please refer to your OS documentation to change these values.
+
+
+
+
+ http_proxy
+
+
{http_proxy || '-'}
+
+
+
+ https_proxy
+
+
{https_proxy || '-'}
+
+
+
+ no_proxy
+
+
{no_proxy || '-'}
+
+
-
-
- Password
-
-
+ ) : null}
+ {formik?.values?.mode === 'on' ? (
+ <>
+
+
+
+ Hostname
+
+ {formik.touched.hostname && formik.errors.hostname ? (
+
{formik.errors.hostname}
+ ) : null}
+
+
+
+ Port
+
+
+ {formik.touched.port && formik.errors.port ? (
+
{formik.errors.port}
+ ) : null}
+
+
+
+ Auth
+
+
- setPasswordVisible(!passwordVisible)}
- >
- {passwordVisible ? : }
-
- {formik.touched.auth?.password && formik.errors.auth?.password ? (
-
{formik.errors.auth.password}
- ) : null}
-
-
-
-
- Proxy Bypass
-
-
- {formik.touched.bypassProxy && formik.errors.bypassProxy ? (
-
{formik.errors.bypassProxy}
- ) : null}
-
+
+
+
+ Username
+
+
+ {formik.touched.auth?.username && formik.errors.auth?.username ? (
+
{formik.errors.auth.username}
+ ) : null}
+
+
+
+ Password
+
+
+
+ setPasswordVisible(!passwordVisible)}
+ >
+ {passwordVisible ? : }
+
+
+ {formik.touched.auth?.password && formik.errors.auth?.password ? (
+
{formik.errors.auth.password}
+ ) : null}
+
+
+
+
+ Proxy Bypass
+
+
+ {formik.touched.bypassProxy && formik.errors.bypassProxy ? (
+
{formik.errors.bypassProxy}
+ ) : null}
+
+ >
+ ) : null}
Save
diff --git a/packages/bruno-app/src/components/Preferences/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/StyledWrapper.js
index 9cb6e7f7e..f43047925 100644
--- a/packages/bruno-app/src/components/Preferences/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Preferences/StyledWrapper.js
@@ -2,13 +2,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
- margin-top: -0.5rem;
-
div.tab {
- padding: 6px 0px;
+ width: 100%;
+ min-width: 120px;
+ padding: 7px 10px;
border: none;
border-bottom: solid 2px transparent;
- margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
@@ -22,8 +21,12 @@ const StyledWrapper = styled.div`
}
&.active {
- color: ${(props) => props.theme.tabs.active.color} !important;
- border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ color: ${(props) => props.theme.sidebar.color} !important;
+ background: ${(props) => props.theme.sidebar.collection.item.bg};
+
+ &:hover {
+ background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
+ }
}
}
}
diff --git a/packages/bruno-app/src/components/Preferences/Support/index.js b/packages/bruno-app/src/components/Preferences/Support/index.js
index dfd6fabed..01aab9e21 100644
--- a/packages/bruno-app/src/components/Preferences/Support/index.js
+++ b/packages/bruno-app/src/components/Preferences/Support/index.js
@@ -1,39 +1,42 @@
import React from 'react';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
+import { useTranslation } from 'react-i18next';
const Support = () => {
+ const { t } = useTranslation();
+
return (
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 843fd8228..2319d4c78 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -1,11 +1,13 @@
import Modal from 'components/Modal/index';
import classnames from 'classnames';
import React, { useState } from 'react';
+
import Support from './Support';
import General from './General';
-import Font from './Font';
-import Theme from './Theme';
import Proxy from './ProxySettings';
+import Display from './Display';
+import Keybindings from './Keybindings';
+
import StyledWrapper from './StyledWrapper';
const Preferences = ({ onClose }) => {
@@ -27,41 +29,43 @@ const Preferences = ({ onClose }) => {
return ;
}
- case 'theme': {
- return ;
+ case 'display': {
+ return ;
+ }
+
+ case 'keybindings': {
+ return ;
}
case 'support': {
return ;
}
-
- case 'font': {
- return ;
- }
}
};
return (
-
-
setTab('general')}>
- General
-
-
setTab('theme')}>
- Theme
-
-
setTab('font')}>
- Font
-
-
setTab('proxy')}>
- Proxy
-
-
setTab('support')}>
- Support
+
+
+
setTab('general')}>
+ General
+
+
setTab('display')}>
+ Display
+
+
setTab('proxy')}>
+ Proxy
+
+
setTab('keybindings')}>
+ Keybindings
+
+
setTab('support')}>
+ Support
+
+
-
);
diff --git a/packages/bruno-app/src/components/ReorderTable/index.js b/packages/bruno-app/src/components/ReorderTable/index.js
new file mode 100644
index 000000000..b5ea369a2
--- /dev/null
+++ b/packages/bruno-app/src/components/ReorderTable/index.js
@@ -0,0 +1,111 @@
+import React, { useEffect, useRef, useState, useMemo } from 'react';
+import { IconGripVertical, IconMinusVertical } from '@tabler/icons';
+
+/**
+ * ReorderTable Component
+ *
+ * A table component that allows rows to be reordered via drag-and-drop.
+ *
+ * @param {Object} props - The component props
+ * @param {React.ReactNode[]} props.children - The table rows as children
+ * @param {function} props.updateReorderedItem - Callback function to handle reordered rows
+ */
+
+const ReorderTable = ({ children, updateReorderedItem }) => {
+ const tbodyRef = useRef();
+ const [hoveredRow, setHoveredRow] = useState(null);
+ const [dragStart, setDragStart] = useState(null);
+
+ const rowsOrder = useMemo(() => React.Children.toArray(children), [children]);
+
+ /**
+ * useEffect hook to handle row hover states
+ */
+ useEffect(() => {
+ handleRowHover(null, false);
+ }, [children]);
+
+ const handleRowHover = (index, hoverstatus = true) => {
+ setHoveredRow(hoverstatus ? index : null);
+ };
+
+ const handleDragStart = (e, index) => {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', index);
+ setDragStart(index);
+ };
+
+ const handleDragOver = (e, index) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ handleRowHover(index);
+ };
+
+ const handleDrop = (e, toIndex) => {
+ e.preventDefault();
+ const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
+ if (fromIndex !== toIndex) {
+ const updatedRowsOrder = [...rowsOrder];
+ const [movedRow] = updatedRowsOrder.splice(fromIndex, 1);
+ updatedRowsOrder.splice(toIndex, 0, movedRow);
+
+ updateReorderedItem({
+ updateReorderedItem: updatedRowsOrder.map((row) => row.props['data-uid'])
+ });
+
+ setTimeout(() => {
+ handleRowHover(toIndex);
+ }, 0);
+ }
+ };
+
+ return (
+
+ {rowsOrder.map((row, index) => (
+ handleDragStart(e, index)}
+ onDragOver={(e) => handleDragOver(e, index)}
+ onDrop={(e) => handleDrop(e, index)}
+ onMouseEnter={() => handleRowHover(index)}
+ onMouseLeave={() => handleRowHover(index, false)}
+ >
+ {React.Children.map(row.props.children, (child, childIndex) => {
+ if (childIndex === 0) {
+ return React.cloneElement(child, {
+ children: (
+ <>
+
+ {hoveredRow === index && (
+ <>
+
+
+ >
+ )}
+
+ {child.props.children}
+ >
+ )
+ });
+ } else {
+ return child;
+ }
+ })}
+
+ ))}
+
+ );
+};
+
+export default ReorderTable;
diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionOperator/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionOperator/index.js
index 068747846..523fcf73d 100644
--- a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionOperator/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionOperator/index.js
@@ -1,7 +1,4 @@
import React from 'react';
-import { useTheme } from 'providers/Theme/index';
-import darkTheme from 'themes/dark';
-import lightTheme from 'themes/light';
/**
* Assertion operators
@@ -23,6 +20,7 @@ import lightTheme from 'themes/light';
* endsWith : ends with
* between : between
* isEmpty : is empty
+ * isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@@ -32,6 +30,7 @@ import lightTheme from 'themes/light';
* isNumber : is number
* isString : is string
* isBoolean : is boolean
+ * isArray : is array
*/
const AssertionOperator = ({ operator, onChange }) => {
@@ -53,6 +52,7 @@ const AssertionOperator = ({ operator, onChange }) => {
'endsWith',
'between',
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -61,7 +61,8 @@ const AssertionOperator = ({ operator, onChange }) => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
const handleChange = (e) => {
@@ -79,16 +80,10 @@ const AssertionOperator = ({ operator, onChange }) => {
}
};
- const { storedTheme } = useTheme();
-
return (
{operators.map((operator) => (
-
+
{getLabel(operator)}
))}
diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
index 5b27061e9..e56e3c398 100644
--- a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
@@ -24,6 +24,7 @@ import { useTheme } from 'providers/Theme';
* endsWith : ends with
* between : between
* isEmpty : is empty
+ * isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@@ -33,6 +34,7 @@ import { useTheme } from 'providers/Theme';
* isNumber : is number
* isString : is string
* isBoolean : is boolean
+ * isArray : is array
*/
const parseAssertionOperator = (str = '') => {
if (!str || typeof str !== 'string' || !str.length) {
@@ -60,6 +62,7 @@ const parseAssertionOperator = (str = '') => {
'endsWith',
'between',
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -68,11 +71,13 @@ const parseAssertionOperator = (str = '') => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
const unaryOperators = [
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -81,10 +86,11 @@ const parseAssertionOperator = (str = '') => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
- const [operator, ...rest] = str.trim().split(' ');
+ const [operator, ...rest] = str.split(' ');
const value = rest.join(' ');
if (unaryOperators.includes(operator)) {
@@ -110,6 +116,7 @@ const parseAssertionOperator = (str = '') => {
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -118,7 +125,8 @@ const isUnaryOperator = (operator) => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
return unaryOperators.includes(operator);
@@ -138,19 +146,8 @@ const AssertionRow = ({
const { operator, value } = parseAssertionOperator(assertion.value);
return (
-
-
- handleAssertionChange(e, assertion, 'name')}
- />
-
+ <>
+
+ onChange={(newValue) => {
handleAssertionChange(
{
target: {
@@ -184,9 +181,11 @@ const AssertionRow = ({
assertion,
'value'
)
+ }
}
onRun={handleRun}
collection={collection}
+ item={item}
/>
) : (
@@ -206,7 +205,7 @@ const AssertionRow = ({
-
+ >
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js
index db0a36e58..efb860893 100644
--- a/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Assertions/StyledWrapper.js
@@ -4,6 +4,7 @@ const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
+ font-weight: 600;
table-layout: fixed;
thead,
@@ -15,24 +16,15 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
- font-weight: 600;
}
td {
padding: 6px 10px;
-
- &:nth-child(1) {
- width: 30%;
}
- &:nth-child(4) {
- width: 70px;
- }
-
- select {
+ select {
background-color: transparent;
}
}
- }
.btn-add-assertion {
font-size: 0.8125rem;
@@ -42,7 +34,8 @@ const Wrapper = styled.div`
width: 100%;
border: solid 1px transparent;
outline: none !important;
- background-color: inherit;
+ color: ${(props) => props.theme.table.input.color};
+ background: transparent;
&:focus {
outline: none !important;
@@ -55,6 +48,9 @@ const Wrapper = styled.div`
position: relative;
top: 1px;
}
+ option {
+ background-color: ${(props) => props.theme.bg};
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/index.js
index 1805a632e..7d173f3c5 100644
--- a/packages/bruno-app/src/components/RequestPane/Assertions/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Assertions/index.js
@@ -6,6 +6,9 @@ import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxS
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import AssertionRow from './AssertionRow';
import StyledWrapper from './StyledWrapper';
+import Table from 'components/Table/index';
+import ReorderTable from 'components/ReorderTable/index';
+import { moveAssertion } from 'providers/ReduxStore/slices/collections/index';
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -57,21 +60,43 @@ const Assertions = ({ item, collection }) => {
);
};
+ const handleAssertionDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveAssertion({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
- Expr
- Operator
- Value
-
-
-
-
+
+
+
+ Add Assertion
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/StyledWrapper.js
new file mode 100644
index 000000000..3ef007f81
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/StyledWrapper.js
@@ -0,0 +1,57 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ label {
+ font-size: 0.8125rem;
+ }
+
+ .single-line-editor-wrapper {
+ max-width: 400px;
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ }
+
+ .auth-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+
+ .dropdown {
+ width: fit-content;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ }
+ }
+ }
+
+ .auth-type-label {
+ width: fit-content;
+ color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
+
+ .caret {
+ color: rgb(140, 140, 140);
+ fill: rgb(140 140 140);
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js
new file mode 100644
index 000000000..513c29500
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js
@@ -0,0 +1,116 @@
+import React, { useRef, forwardRef, useEffect } from 'react';
+import { useDispatch } from 'react-redux';
+import get from 'lodash/get';
+import { IconCaretDown } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import { useTheme } from 'providers/Theme';
+import SingleLineEditor from 'components/SingleLineEditor';
+import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
+
+const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
+ const apikeyAuth = get(request, 'auth.apikey', {});
+
+ const handleRun = () => dispatch(sendRequest(item, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+ {humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
+
+
+ );
+ });
+
+ const handleAuthChange = (property, value) => {
+ dispatch(
+ updateAuth({
+ mode: 'apikey',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ ...apikeyAuth,
+ [property]: value
+ }
+ })
+ );
+ };
+
+ useEffect(() => {
+ !apikeyAuth?.placement &&
+ dispatch(
+ updateAuth({
+ mode: 'apikey',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ placement: 'header'
+ }
+ })
+ );
+ }, [apikeyAuth]);
+
+ return (
+
+ Key
+
+ handleAuthChange('key', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ Value
+
+ handleAuthChange('value', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ Add To
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef?.current?.hide();
+ handleAuthChange('placement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef?.current?.hide();
+ handleAuthChange('placement', 'queryparams');
+ }}
+ >
+ Query Param
+
+
+
+
+ );
+};
+
+export default ApiKeyAuth;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
index 2367d9645..1e3bedc2f 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
@@ -30,7 +30,6 @@ const AuthMode = ({ item, collection }) => {
})
);
};
-
return (
@@ -71,6 +70,15 @@ const AuthMode = ({ item, collection }) => {
>
Digest Auth
+ {
+ dropdownTippyRef?.current?.hide();
+ onModeChange('ntlm');
+ }}
+ >
+ NTLM Auth
+
{
@@ -80,6 +88,24 @@ const AuthMode = ({ item, collection }) => {
>
OAuth 2.0
+ {
+ dropdownTippyRef?.current?.hide();
+ onModeChange('wsse');
+ }}
+ >
+ WSSE Auth
+
+ {
+ dropdownTippyRef?.current?.hide();
+ onModeChange('apikey');
+ }}
+ >
+ API Key
+
{
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
index 7c144fbf8..75469d784 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
-const AwsV4Auth = ({ onTokenChange, item, collection }) => {
+const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
+ const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(
@@ -136,6 +139,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleAccessKeyIdChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -148,6 +152,8 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleSecretAccessKeyChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={true}
/>
@@ -160,6 +166,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleSessionTokenChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -172,6 +179,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleServiceChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -184,6 +192,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleRegionChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -196,6 +205,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onChange={(val) => handleProfileNameChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
index 845dae273..ef714f528 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const BasicAuth = ({ item, collection }) => {
+const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
+ const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUsernameChange = (username) => {
dispatch(
@@ -55,6 +58,7 @@ const BasicAuth = ({ item, collection }) => {
onChange={(val) => handleUsernameChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -67,6 +71,8 @@ const BasicAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={true}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
index 77198d311..c8ba9d1c6 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const BearerAuth = ({ item, collection }) => {
+const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const bearerToken = item.draft
- ? get(item, 'draft.request.auth.bearer.token', '')
- : get(item, 'request.auth.bearer.token', '');
+ // Use the request prop directly like OAuth2ClientCredentials does
+ const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleTokenChange = (token) => {
dispatch(
@@ -42,6 +44,8 @@ const BearerAuth = ({ item, collection }) => {
onChange={(val) => handleTokenChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={true}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
index e43f18c46..50b92f669 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
@@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const DigestAuth = ({ item, collection }) => {
+const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
+ const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUsernameChange = (username) => {
dispatch(
@@ -55,6 +57,7 @@ const DigestAuth = ({ item, collection }) => {
onChange={(val) => handleUsernameChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -67,6 +70,8 @@ const DigestAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={true}
/>
diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/PasswordCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/StyledWrapper.js
similarity index 99%
rename from packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
rename to packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/StyledWrapper.js
index 856f35b9b..316d3a7c5 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/StyledWrapper.js
@@ -4,6 +4,7 @@ const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
+
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js
new file mode 100644
index 000000000..1164fb903
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js
@@ -0,0 +1,112 @@
+import React from 'react';
+import get from 'lodash/get';
+import { useTheme } from 'providers/Theme';
+import { useDispatch } from 'react-redux';
+import SingleLineEditor from 'components/SingleLineEditor';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+
+const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+
+ const ntlmAuth = get(request, 'auth.ntlm', {});
+
+ const handleRun = () => dispatch(sendRequest(item, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
+
+ const handleUsernameChange = (username) => {
+ dispatch(
+ updateAuth({
+ mode: 'ntlm',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ username: username,
+ password: ntlmAuth.password,
+ domain: ntlmAuth.domain
+ }
+ })
+ );
+ };
+
+ const handlePasswordChange = (password) => {
+ dispatch(
+ updateAuth({
+ mode: 'ntlm',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ username: ntlmAuth.username,
+ password: password,
+ domain: ntlmAuth.domain
+ }
+ })
+ );
+ };
+
+ const handleDomainChange = (domain) => {
+ dispatch(
+ updateAuth({
+ mode: 'ntlm',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ username: ntlmAuth.username,
+ password: ntlmAuth.password,
+ domain: domain
+ }
+ })
+ );
+ };
+
+ return (
+
+ Username
+
+ handleUsernameChange(val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+ Password
+
+ handlePasswordChange(val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ isSecret={true}
+ />
+
+
+ Domain
+
+ handleDomainChange(val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+ );
+};
+
+export default NTLMAuth;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
index 08a77555c..c00964d82 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
@@ -1,28 +1,63 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
-import { clearOauth2Cache } from 'utils/network/index';
-import toast from 'react-hot-toast';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
-const OAuth2AuthorizationCode = ({ item, collection }) => {
+const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const oAuth = get(request, 'auth.oauth2', {});
+ const {
+ callbackUrl,
+ authorizationUrl,
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ state,
+ pkce,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken
+ } = oAuth;
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
- const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, pkce } = oAuth;
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
+
+ const handleSave = () => { save(); };
const handleChange = (key, value) => {
dispatch(
@@ -37,9 +72,18 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
accessTokenUrl,
clientId,
clientSecret,
+ state,
scope,
pkce,
- [key]: value
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ [key]: value,
}
})
);
@@ -58,31 +102,37 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
accessTokenUrl,
clientId,
clientSecret,
+ state,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ autoFetchToken,
pkce: !Boolean(oAuth?.['pkce'])
}
})
);
};
- const handleClearCache = (e) => {
- clearOauth2Cache(collection?.uid)
- .then(() => {
- toast.success('cleared cache successfully');
- })
- .catch((err) => {
- toast.error(err.message);
- });
- };
-
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
- const { key, label } = input;
+ const { key, label, isSecret } = input;
return (
-
-
{label}
-
+
+
{label}
+
{
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={isSecret}
/>
);
})}
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
- Use PKCE
+ Use PKCE
{
onChange={handlePKCEToggle}
/>
-
-
- Get Access Token
-
-
- Clear Cache
-
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
);
};
-export default OAuth2AuthorizationCode;
+export default OAuth2AuthorizationCode;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/inputsConfig.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/inputsConfig.js
index f7cc7801a..a100ce8e5 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/inputsConfig.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/inputsConfig.js
@@ -17,11 +17,16 @@ const inputsConfig = [
},
{
key: 'clientSecret',
- label: 'Client Secret'
+ label: 'Client Secret',
+ isSecret: true
},
{
key: 'scope',
label: 'Scope'
+ },
+ {
+ key: 'state',
+ label: 'State'
}
];
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
index 7edb8bb25..64ab7c408 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
@@ -1,26 +1,61 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import Dropdown from 'components/Dropdown';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
-const OAuth2ClientCredentials = ({ item, collection }) => {
+const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const oAuth = get(request, 'auth.oauth2', {});
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken
+ } = oAuth;
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
+
+ const handleSave = () => { save(); };
+
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
+
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
const handleChange = (key, value) => {
dispatch(
@@ -34,6 +69,14 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
clientId,
clientSecret,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
[key]: value
}
})
@@ -42,12 +85,21 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
- const { key, label } = input;
+ const { key, label, isSecret } = input;
return (
-
-
{label}
-
+
+
{label}
+
{
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={isSecret}
/>
);
})}
-
- Get Access Token
-
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/inputsConfig.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/inputsConfig.js
index 164dcaab4..f2cd88ae3 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/inputsConfig.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/inputsConfig.js
@@ -9,7 +9,8 @@ const inputsConfig = [
},
{
key: 'clientSecret',
- label: 'Client Secret'
+ label: 'Client Secret',
+ isSecret: true
},
{
key: 'scope',
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
index 3fa12b947..e468845e4 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
@@ -3,18 +3,20 @@ import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
-import { IconCaretDown } from '@tabler/icons';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { IconCaretDown, IconKey } from '@tabler/icons';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
+import { useState } from 'react';
-const GrantTypeSelector = ({ item, collection }) => {
+const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
+ const oAuth = get(request, 'auth.oauth2', {});
+ const [valuesCache, setValuesCache] = useState({
+ ...oAuth
+ });
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
-
const Icon = forwardRef((props, ref) => {
return (
@@ -24,13 +26,19 @@ const GrantTypeSelector = ({ item, collection }) => {
});
const onGrantTypeChange = (grantType) => {
+ let updatedValues = {
+ ...valuesCache,
+ ...oAuth,
+ grantType
+ };
+ setValuesCache(updatedValues);
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- grantType
+ ...updatedValues
}
})
);
@@ -46,7 +54,18 @@ const GrantTypeSelector = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- grantType: 'authorization_code'
+ grantType: 'authorization_code',
+ accessTokenUrl: '',
+ username: '',
+ password: '',
+ clientId: '',
+ clientSecret: '',
+ scope: '',
+ credentialsPlacement: 'body',
+ credentialsId: 'credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
}
})
);
@@ -54,7 +73,14 @@ const GrantTypeSelector = ({ item, collection }) => {
return (
- Grant Type
+
} placement="bottom-end">
{
);
};
-export default GrantTypeSelector;
+export default GrantTypeSelector;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js
new file mode 100644
index 000000000..7b45f03ea
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js
@@ -0,0 +1,95 @@
+import { useMemo, useState } from "react";
+import { useDispatch } from "react-redux";
+import toast from 'react-hot-toast';
+import { cloneDeep, find } from 'lodash';
+import { IconLoader2 } from '@tabler/icons';
+import { interpolate } from '@usebruno/common';
+import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
+import { getAllVariables } from "utils/collections/index";
+
+const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
+ const { uid: collectionUid } = collection;
+
+ const dispatch = useDispatch();
+ const [fetchingToken, toggleFetchingToken] = useState(false);
+ const [refreshingToken, toggleRefreshingToken] = useState(false);
+
+ const interpolatedAccessTokenUrl = useMemo(() => {
+ const variables = getAllVariables(collection, item);
+ return interpolate(accessTokenUrl, variables);
+ }, [collection, item, accessTokenUrl]);
+
+ const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedAccessTokenUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
+ const creds = credentialsData?.credentials || {};
+
+ const handleFetchOauth2Credentials = async () => {
+ let requestCopy = cloneDeep(request);
+ requestCopy.oauth2 = requestCopy?.auth.oauth2;
+ requestCopy.headers = {};
+ toggleFetchingToken(true);
+ try {
+ const credentials = await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
+ toggleFetchingToken(false);
+ if (credentials?.access_token) {
+ toast.success('token fetched successfully!');
+ }
+ else {
+ toast.error('An error occurred while fetching token!');
+ }
+ }
+ catch (error) {
+ console.error('could not fetch the token!');
+ console.error(error);
+ toggleFetchingToken(false);
+ toast.error('An error occurred while fetching token!');
+ }
+ }
+
+ const handleRefreshAccessToken = async () => {
+ let requestCopy = cloneDeep(request);
+ requestCopy.oauth2 = requestCopy?.auth.oauth2;
+ requestCopy.headers = {};
+ toggleRefreshingToken(true);
+ try {
+ const credentials = await dispatch(refreshOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
+ toggleRefreshingToken(false);
+ if (credentials?.access_token) {
+ toast.success('token refreshed successfully!');
+ }
+ else {
+ toast.error('An error occurred while refreshing token!');
+ }
+ }
+ catch(error) {
+ console.error(error);
+ toggleRefreshingToken(false);
+ toast.error('An error occurred while refreshing token!');
+ }
+ };
+
+ const handleClearCache = (e) => {
+ dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
+ .then(() => {
+ toast.success('cleared cache successfully');
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ };
+
+ return (
+
+
+ Get Access Token{fetchingToken? : ""}
+
+ {creds?.refresh_token ?
+ Refresh Token{refreshingToken? : ""}
+ : null}
+
+ Clear Cache
+
+
+ )
+}
+
+export default Oauth2ActionButtons;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js
new file mode 100644
index 000000000..80d13c0e5
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ ol[role="tree"] {
+ overflow: hidden;
+ }
+ ol[role="group"] span {
+ line-break: anywhere;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js
new file mode 100644
index 000000000..7692b5891
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js
@@ -0,0 +1,172 @@
+import { useState, useEffect, useMemo } from "react";
+import { find } from "lodash";
+import StyledWrapper from "./StyledWrapper";
+import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
+import { getAllVariables } from 'utils/collections/index';
+import { interpolate } from '@usebruno/common';
+
+const TokenSection = ({ title, token }) => {
+ if (!token) return null;
+
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [decodedToken, setDecodedToken] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ if (token) {
+ try {
+ const parts = token.split('.');
+ if (parts.length === 3) {
+ const payload = JSON.parse(atob(parts[1]));
+ setDecodedToken(payload);
+ }
+ } catch (err) {
+ console.error('Error decoding token:', err);
+ }
+ }
+ }, [token]);
+
+ const handleCopy = async (text) => {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+ {isExpanded ?
+
:
+
+ }
+
+
{title}
+ {decodedToken?.exp && }
+
+
+
+ {isExpanded && (
+
+
+
+ handleCopy(token)}
+ className="p-1 bg-indigo-100 dark:hover:bg-indigo-200 rounded"
+ title="Copy token"
+ >
+ {copied ?
+ :
+
+ }
+
+
+
+ {token}
+
+
+ {decodedToken && (
+
+
Decoded Payload
+
+ {Object.entries(decodedToken).map(([key, value]) => (
+
+ {key}:
+
+ {typeof value === 'object' ? JSON.stringify(value) : value.toString()}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+};
+
+const formatExpiryTime = (seconds) => {
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+};
+
+const ExpiryTimer = ({ expiresIn }) => {
+ if (!expiresIn) return null;
+
+ const calculateTimeLeft = () => Math.max(0, Math.floor(expiresIn - Date.now() / 1000));
+
+ const [timeLeft, setTimeLeft] = useState(calculateTimeLeft);
+
+ useEffect(() => {
+ setTimeLeft(calculateTimeLeft());
+
+ const timer = setInterval(() => {
+ setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [expiresIn]);
+
+ return (
+
+ {timeLeft > 0 ? `Expires in ${formatExpiryTime(timeLeft)}` : `Expired`}
+
+ );
+};
+
+
+const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) => {
+ const { uid: collectionUid } = collection;
+
+ const interpolatedUrl = useMemo(() => {
+ const variables = getAllVariables(collection, item);
+ return interpolate(url, variables);
+ }, [collection, item, url]);
+
+ const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
+ const creds = credentialsData?.credentials || {};
+
+ return (
+
+ {Object.keys(creds)?.length ? (
+ creds?.error ? (
+ Error fetching token. Check network logs for more details.
+ ) : (
+
+
+
+
+ {(creds.token_type || creds.scope) ?
+
+ {creds.token_type ?
+ Token Type:
+ {creds.token_type}
+
: null}
+ {creds?.scope ?
+ Scope:
+
+ {creds.scope}
+
+
: null}
+
+
: null}
+
+ )
+ ) : (
+ No token found
+ )}
+
+ );
+};
+
+export default Oauth2TokenViewer;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
index 1e64d4faa..385607848 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
@@ -1,26 +1,62 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import Dropdown from 'components/Dropdown';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
-const OAuth2AuthorizationCode = ({ item, collection }) => {
+const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const oAuth = get(request, 'auth.oauth2', {});
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const {
+ accessTokenUrl,
+ username,
+ password,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken
+ } = oAuth;
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
+ const handleSave = () => { save(); }
+
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
+
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
const handleChange = (key, value) => {
dispatch(
@@ -36,6 +72,14 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientId,
clientSecret,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
[key]: value
}
})
@@ -44,12 +88,21 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
- const { key, label } = input;
+ const { key, label, isSecret } = input;
return (
-
-
{label}
-
+
+
{label}
+
{
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={isSecret}
/>
);
})}
-
- Get Access Token
-
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
);
};
-export default OAuth2AuthorizationCode;
+export default OAuth2PasswordCredentials;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/inputsConfig.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/inputsConfig.js
index 6366bb5e7..32f2c999c 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/inputsConfig.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/inputsConfig.js
@@ -9,7 +9,8 @@ const inputsConfig = [
},
{
key: 'password',
- label: 'Password'
+ label: 'Password',
+ isSecret: true
},
{
key: 'clientId',
@@ -17,7 +18,8 @@ const inputsConfig = [
},
{
key: 'clientSecret',
- label: 'Client Secret'
+ label: 'Client Secret',
+ isSecret: true
},
{
key: 'scope',
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
index 3965c8d3e..bbee9d4f5 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
@@ -5,17 +5,34 @@ import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+
+const GrantTypeComponentMap = ({ item, collection }) => {
+ const dispatch = useDispatch();
+
+ const save = () => {
+ dispatch(saveRequest(item.uid, collection.uid));
+ };
+
+ let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
+ const grantType = get(request, 'auth.oauth2.grantType', {});
+
+ const handleRun = async () => {
+ dispatch(sendRequest(item, collection.uid));
+ };
+
-const grantTypeComponentMap = (grantType, item, collection) => {
switch (grantType) {
case 'password':
- return
;
+ return
;
break;
case 'authorization_code':
- return
;
+ return
;
break;
case 'client_credentials':
- return
;
+ return
;
break;
default:
return
TBD
;
@@ -24,12 +41,12 @@ const grantTypeComponentMap = (grantType, item, collection) => {
};
const OAuth2 = ({ item, collection }) => {
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
return (
-
- {grantTypeComponentMap(oAuth?.grantType, item, collection)}
+
+
);
};
diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/ClientCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/StyledWrapper.js
similarity index 99%
rename from packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/ClientCredentials/StyledWrapper.js
rename to packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/StyledWrapper.js
index 856f35b9b..316d3a7c5 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/ClientCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/StyledWrapper.js
@@ -4,6 +4,7 @@ const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
+
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js
new file mode 100644
index 000000000..ae201370e
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import get from 'lodash/get';
+import { useTheme } from 'providers/Theme';
+import { useDispatch } from 'react-redux';
+import SingleLineEditor from 'components/SingleLineEditor';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+
+const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+
+ const wsseAuth = get(request, 'auth.wsse', {});
+
+ const handleRun = () => dispatch(sendRequest(item, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
+
+ const handleUserChange = (username) => {
+ dispatch(
+ updateAuth({
+ mode: 'wsse',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ username,
+ password: wsseAuth.password
+ }
+ })
+ );
+ };
+
+ const handlePasswordChange = (password) => {
+ dispatch(
+ updateAuth({
+ mode: 'wsse',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ username: wsseAuth.username,
+ password
+ }
+ })
+ );
+ };
+
+ return (
+
+ Username
+
+ handleUserChange(val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+ Password
+
+ handlePasswordChange(val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ isSecret={true}
+ />
+
+
+ );
+};
+
+export default WsseAuth;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/index.js
index 51a1450f1..8ca23ab8d 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/index.js
@@ -5,60 +5,111 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
+import WsseAuth from './WsseAuth';
+import NTLMAuth from './NTLMAuth';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+
+import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
-import { humanizeRequestAuthMode } from 'utils/collections/index';
+import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
+import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
+
+const getTreePathFromCollectionToItem = (collection, _item) => {
+ let path = [];
+ let item = findItemInCollection(collection, _item?.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
+ }
+ return path;
+};
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+
+ // Create a request object to pass to the auth components
+ const request = item.draft
+ ? get(item, 'draft.request', {})
+ : get(item, 'request', {});
- const collectionRoot = get(collection, 'root', {});
- const collectionAuth = get(collectionRoot, 'request.auth');
+ // Save function for request level
+ const save = () => {
+ return saveRequest(item.uid, collection.uid);
+ };
+
+ const getEffectiveAuthSource = () => {
+ if (authMode !== 'inherit') return null;
+
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ name: 'Collection',
+ auth: collectionAuth
+ };
+
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...requestTreePath].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ name: i.name,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+ };
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
- return
;
+ return
;
}
case 'basic': {
- return
;
+ return
;
}
case 'bearer': {
- return
;
+ return
;
}
case 'digest': {
- return
;
+ return
;
}
+ case 'ntlm': {
+ return
;
+ }
case 'oauth2': {
- return
;
+ return
;
+ }
+ case 'wsse': {
+ return
;
+ }
+ case 'apikey': {
+ return
;
}
case 'inherit': {
+ const source = getEffectiveAuthSource();
return (
-
- {collectionAuth?.mode === 'oauth2' ? (
-
-
-
Collection level auth is:
-
{humanizeRequestAuthMode(collectionAuth?.mode)}
-
-
- Note: You need to use scripting to set the access token in the request headers.
-
-
- ) : (
- <>
-
Auth inherited from the Collection:
-
{humanizeRequestAuthMode(collectionAuth?.mode)}
- >
- )}
-
+ <>
+
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ >
);
}
}
};
return (
-
+
diff --git a/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js
new file mode 100644
index 000000000..35adfcc1f
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js
@@ -0,0 +1,65 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(2) {
+ width: 45%;
+ }
+
+ &:nth-child(3) {
+ width: 25%;
+ }
+
+ &:nth-child(4) {
+ width: 70px;
+ }
+ }
+ }
+
+ .btn-add-param {
+ font-size: 0.8125rem;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ color: ${(props) => props.theme.table.input.color};
+ background: transparent;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='radio'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/FileBody/index.js b/packages/bruno-app/src/components/RequestPane/FileBody/index.js
new file mode 100644
index 000000000..d97953aa5
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/FileBody/index.js
@@ -0,0 +1,164 @@
+import React, { useState, useEffect } from 'react';
+import { get, cloneDeep, isArray } from 'lodash';
+import { IconTrash } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { addFile as _addFile, updateFile, deleteFile } from 'providers/ReduxStore/slices/collections/index';
+import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+import FilePickerEditor from 'components/FilePickerEditor/index';
+import SingleLineEditor from 'components/SingleLineEditor/index';
+
+const FileBody = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const params = item.draft ? get(item, 'draft.request.body.file') : get(item, 'request.body.file');
+
+ const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : '');
+
+ const addFile = () => {
+ dispatch(
+ _addFile({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ })
+ );
+ };
+
+ const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const handleRun = () => dispatch(sendRequest(item, collection.uid));
+
+ const handleParamChange = (e, _param, type) => {
+ const param = cloneDeep(_param);
+ switch (type) {
+ case 'filePath': {
+ param.filePath = e.target.filePath;
+ param.contentType = "";
+ break;
+ }
+ case 'contentType': {
+ param.contentType = e.target.contentType;
+ break;
+ }
+ case 'selected': {
+ param.selected = e.target.selected;
+ setEnableFileUid(param.uid)
+ break;
+ }
+ }
+ dispatch(
+ updateFile({
+ param: param,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleRemoveParams = (param) => {
+ dispatch(
+ deleteFile({
+ paramUid: param.uid,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ return (
+
+
+
+
+
+ File
+
+
+ Content-Type
+
+
+ Selected
+
+
+
+
+
+ {params && params.length
+ ? params.map((param, index) => {
+ return (
+
+
+
+ handleParamChange(
+ {
+ target: {
+ filePath: path
+ }
+ },
+ param,
+ 'filePath'
+ )
+ }
+ collection={collection}
+ />
+
+
+
+ handleParamChange(
+ {
+ target: {
+ contentType: newValue
+ }
+ },
+ param,
+ 'contentType'
+ )
+ }
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+
+ handleParamChange(e, param, 'selected')}
+ />
+
+
+
+
+ handleRemoveParams(param)}>
+
+
+
+
+
+ );
+ })
+ : null}
+
+
+
+
+ + Add File
+
+
+
+ );
+};
+export default FileBody;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/StyledWrapper.js
index f04a30be0..517415935 100644
--- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/StyledWrapper.js
@@ -19,16 +19,8 @@ const Wrapper = styled.div`
}
td {
padding: 6px 10px;
-
- &:nth-child(1) {
- width: 30%;
- }
-
- &:nth-child(3) {
- width: 70px;
}
}
- }
.btn-add-param {
font-size: 0.8125rem;
diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
index a358e2ed3..c8eeda531 100644
--- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
@@ -7,11 +7,14 @@ import { useTheme } from 'providers/Theme';
import {
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
- deleteFormUrlEncodedParam
+ deleteFormUrlEncodedParam,
+ moveFormUrlEncodedParam
} from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import ReorderTable from 'components/ReorderTable/index';
+import Table from 'components/Table/index';
const FormUrlEncodedParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -64,74 +67,84 @@ const FormUrlEncodedParams = ({ item, collection }) => {
);
};
+ const handleParamDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveFormUrlEncodedParam({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
- Key
- Value
-
-
-
-
+
+
{params && params.length
? params.map((param, index) => {
- return (
-
-
+ return (
+
+
+ handleParamChange(e, param, 'name')}
+ />
+
+
+
+ handleParamChange(
+ {
+ target: {
+ value: newValue
+ }
+ },
+ param,
+ 'value'
+ )
+ }
+ allowNewlines={true}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
handleParamChange(e, param, 'name')}
+ type="checkbox"
+ checked={param.enabled}
+ tabIndex="-1"
+ className="mr-3 mousetrap"
+ onChange={(e) => handleParamChange(e, param, 'enabled')}
/>
-
-
-
- handleParamChange(
- {
- target: {
- value: newValue
- }
- },
- param,
- 'value'
- )
- }
- allowNewlines={true}
- onRun={handleRun}
- collection={collection}
- />
-
-
-
- handleParamChange(e, param, 'enabled')}
- />
- handleRemoveParams(param)}>
-
-
-
-
-
- );
- })
+ handleRemoveParams(param)}>
+
+
+
+
+
+ );
+ })
: null}
-
-
+
+
+ Add Param
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
index 5bdd9c5e7..07dcf1419 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
@@ -31,6 +31,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
: get(item, 'request.body.graphql.variables');
const { displayedTheme } = useTheme();
const [schema, setSchema] = useState(null);
+ const preferences = useSelector((state) => state.app.preferences);
useEffect(() => {
onSchemaLoad(schema);
@@ -71,6 +72,8 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
onRun={onRun}
onEdit={onQueryChange}
onClickReference={handleGqlClickReference}
+ font={get(preferences, 'font.codeFont', 'default')}
+ fontSize={get(preferences, 'font.codeFontSize')}
/>
);
}
@@ -151,7 +154,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
- {getTabPanel(focusedTab.requestPaneTab)}
+ {getTabPanel(focusedTab.requestPaneTab)}
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
index 8fe747389..3b1cc6109 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
@@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
+ const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
+ const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
- const request = item.draft ? item.draft.request : item.request;
+ const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
let {
schema,
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js
index 0a5f2bd01..5b1b6c277 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/useGraphqlSchema.js
@@ -1,8 +1,8 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
-import { buildClientSchema } from 'graphql';
+import { buildClientSchema, buildSchema } from 'graphql';
import { fetchGqlSchema } from 'utils/network';
-import { simpleHash } from 'utils/common';
+import { simpleHash, safeParseJSON } from 'utils/common';
const schemaHashPrefix = 'bruno.graphqlSchema';
@@ -18,7 +18,12 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
if (!saved) {
return null;
}
- return buildClientSchema(JSON.parse(saved));
+ let parsedData = safeParseJSON(saved);
+ if (typeof parsedData === 'object') {
+ return buildClientSchema(parsedData);
+ } else {
+ return buildSchema(parsedData);
+ }
} catch {
localStorage.setItem(localStorageKey, null);
return null;
@@ -48,7 +53,7 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
return;
}
setSchemaSource('file');
- return schemaContent.data;
+ return schemaContent?.data || schemaContent;
};
const loadSchema = async (schemaSource) => {
@@ -66,11 +71,18 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
// fallback to introspection if source is unknown
data = await loadSchemaFromIntrospection();
}
- setSchema(buildClientSchema(data));
- localStorage.setItem(localStorageKey, JSON.stringify(data));
- toast.success('GraphQL Schema loaded successfully');
+ if (data) {
+ if (typeof data === 'object') {
+ setSchema(buildClientSchema(data));
+ } else {
+ setSchema(buildSchema(data));
+ }
+ localStorage.setItem(localStorageKey, JSON.stringify(data));
+ toast.success('GraphQL Schema loaded successfully');
+ }
} catch (err) {
setError(err);
+ console.error(err);
toast.error(`Error occurred while loading GraphQL Schema: ${err.message}`);
}
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
index a7978ebd7..228a54fa8 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
@@ -6,6 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
+import { format, applyEdits } from 'jsonc-parser';
+import { IconWand } from '@tabler/icons';
+import toast from 'react-hot-toast';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -13,6 +16,25 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
+ const onPrettify = () => {
+ if (!variables) return;
+ try {
+ const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true });
+ const prettyVariables = applyEdits(variables, edits);
+ dispatch(
+ updateRequestGraphqlVariables({
+ variables: prettyVariables,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ toast.success('Variables prettified');
+ } catch (error) {
+ console.error(error);
+ toast.error('Error occurred while prettifying GraphQL variables');
+ }
+ };
+
const onEdit = (value) => {
dispatch(
updateRequestGraphqlVariables({
@@ -27,18 +49,27 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
-
+ <>
+
+
+
-
+ >
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/StyledWrapper.js
index d78558bf7..e6a766672 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/StyledWrapper.js
@@ -23,6 +23,10 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
+
+ .content-indicator {
+ color: ${(props) => props.theme.text}
+ }
}
}
`;
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
index 834751848..4c7e6029b 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
@@ -1,5 +1,4 @@
import React from 'react';
-import find from 'lodash/find';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -8,14 +7,31 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
-import AuthMode from 'components/RequestPane/Auth/AuthMode';
+import DotIcon from 'components/Icons/Dot';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
-import { get } from 'lodash';
+import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
+import { useEffect } from 'react';
+
+const ContentIndicator = () => {
+ return (
+
+
+
+ );
+};
+
+const ErrorIndicator = () => {
+ return (
+
+
+
+ );
+};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const dispatch = useDispatch();
@@ -81,12 +97,21 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
});
};
- // get the length of active params, headers, asserts and vars
- const params = item.draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []);
- const headers = item.draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []);
- const assertions = item.draft ? get(item, 'draft.request.assertions', []) : get(item, 'request.assertions', []);
- const requestVars = item.draft ? get(item, 'draft.request.vars.req', []) : get(item, 'request.vars.req', []);
- const responseVars = item.draft ? get(item, 'draft.request.vars.res', []) : get(item, 'request.vars.res', []);
+ const isMultipleContentTab = ['params', 'script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
+
+ // get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
+ const getPropertyFromDraftOrRequest = (propertyKey) =>
+ item.draft ? get(item, `draft.${propertyKey}`, []) : get(item, propertyKey, []);
+ const params = getPropertyFromDraftOrRequest('request.params');
+ const body = getPropertyFromDraftOrRequest('request.body');
+ const headers = getPropertyFromDraftOrRequest('request.headers');
+ const script = getPropertyFromDraftOrRequest('request.script');
+ const assertions = getPropertyFromDraftOrRequest('request.assertions');
+ const tests = getPropertyFromDraftOrRequest('request.tests');
+ const docs = getPropertyFromDraftOrRequest('request.docs');
+ const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
+ const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
+ const auth = getPropertyFromDraftOrRequest('request.auth');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@@ -95,22 +120,30 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
requestVars.filter((request) => request.enabled).length +
responseVars.filter((response) => response.enabled).length;
+ useEffect(() => {
+ if (activeParamsLength === 0 && body.mode !== 'none') {
+ selectTab('body');
+ }
+ }, []);
+
return (
selectTab('params')}>
- Query
+ Params
{activeParamsLength > 0 && {activeParamsLength} }
selectTab('body')}>
Body
+ {body.mode !== 'none' && }
selectTab('headers')}>
Headers
- {activeHeadersLength > 0 && {activeHeadersLength} }
+ {activeHeadersLength > 0 && {activeHeadersLength} }
selectTab('auth')}>
Auth
+ {auth.mode !== 'none' && }
selectTab('vars')}>
Vars
@@ -118,6 +151,11 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('script')}>
Script
+ {(script.req || script.res) && (
+ item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
+ :
+
+ )}
selectTab('assert')}>
Assert
@@ -125,9 +163,11 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('tests')}>
Tests
+ {tests && tests.length > 0 && }
selectTab('docs')}>
Docs
+ {docs && docs.length > 0 && }
{focusedTab.requestPaneTab === 'body' ? (
@@ -136,9 +176,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
) : null}
{getTabPanel(focusedTab.requestPaneTab)}
diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
index f04a30be0..f7b6e5d13 100644
--- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
@@ -19,15 +19,7 @@ const Wrapper = styled.div`
}
td {
padding: 6px 10px;
-
- &:nth-child(1) {
- width: 30%;
}
-
- &:nth-child(3) {
- width: 70px;
- }
- }
}
.btn-add-param {
diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
index 1f1c9977e..790f7d5fa 100644
--- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
@@ -7,12 +7,15 @@ import { useTheme } from 'providers/Theme';
import {
addMultipartFormParam,
updateMultipartFormParam,
- deleteMultipartFormParam
+ deleteMultipartFormParam,
+ moveMultipartFormParam
} from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
+import Table from 'components/Table/index';
+import ReorderTable from 'components/ReorderTable/index';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -24,7 +27,8 @@ const MultipartFormParams = ({ item, collection }) => {
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
- type: 'text'
+ type: 'text',
+ value: ''
})
);
};
@@ -34,7 +38,8 @@ const MultipartFormParams = ({ item, collection }) => {
addMultipartFormParam({
itemUid: item.uid,
collectionUid: collection.uid,
- type: 'file'
+ type: 'file',
+ value: []
})
);
};
@@ -52,6 +57,10 @@ const MultipartFormParams = ({ item, collection }) => {
param.value = e.target.value;
break;
}
+ case 'contentType': {
+ param.contentType = e.target.value;
+ break;
+ }
case 'enabled': {
param.enabled = e.target.checked;
break;
@@ -76,92 +85,124 @@ const MultipartFormParams = ({ item, collection }) => {
);
};
+ const handleParamDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveMultipartFormParam({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
- Key
- Value
-
-
-
-
+
+
{params && params.length
? params.map((param, index) => {
- return (
-
-
- handleParamChange(e, param, 'name')}
+ return (
+
+
+ handleParamChange(e, param, 'name')}
+ />
+
+
+ {param.type === 'file' ? (
+
+ handleParamChange(
+ {
+ target: {
+ value: newValue
+ }
+ },
+ param,
+ 'value'
+ )
+ }
+ collection={collection}
/>
-
-
- {param.type === 'file' ? (
-
- handleParamChange(
- {
- target: {
- value: newValue
- }
- },
- param,
- 'value'
- )
- }
- collection={collection}
- />
- ) : (
-
- handleParamChange(
- {
- target: {
- value: newValue
- }
- },
- param,
- 'value'
- )
- }
- onRun={handleRun}
- allowNewlines={true}
- collection={collection}
- />
- )}
-
-
-
- handleParamChange(e, param, 'enabled')}
- />
- handleRemoveParams(param)}>
-
-
-
-
-
- );
- })
+ ) : (
+
+ handleParamChange(
+ {
+ target: {
+ value: newValue
+ }
+ },
+ param,
+ 'value'
+ )
+ }
+ onRun={handleRun}
+ allowNewlines={true}
+ collection={collection}
+ item={item}
+ />
+ )}
+
+
+
+ handleParamChange(
+ {
+ target: {
+ value: newValue
+ }
+ },
+ param,
+ 'contentType'
+ )
+ }
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+
+ handleParamChange(e, param, 'enabled')}
+ />
+ handleRemoveParams(param)}>
+
+
+
+
+
+ );
+ })
: null}
-
-
+
+
+ Add Param
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
index 99d5ed3b9..57b8d4987 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
@@ -4,8 +4,9 @@ const StyledWrapper = styled.div`
div.CodeMirror {
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
- /* todo: find a better way */
- height: calc(100vh - 220px);
+ font-family: ${(props) => (props.font ? props.font : 'default')};
+ font-size: ${(props) => (props.fontSize ? `${props.fontSize}px` : 'inherit')};
+ flex: 1 1 0;
}
textarea.cm-editor {
@@ -49,6 +50,18 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: red;
}
+
+ .CodeMirror-search-hint {
+ display: inline;
+ }
+
+ .cm-s-default span.cm-property {
+ color: #1f61a0 !important;
+ }
+
+ .cm-s-default span.cm-variable {
+ color: #397d13 !important;
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
index 598af0212..6571c14ae 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
@@ -19,7 +19,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@@ -109,7 +109,7 @@ export default class QueryEditor extends React.Component {
this.props.onPrettifyQuery();
}
},
- /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy */
+ /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Prettify */
'Shift-Ctrl-F': () => {
if (this.props.onPrettifyQuery) {
this.props.onPrettifyQuery();
@@ -209,8 +209,10 @@ export default class QueryEditor extends React.Component {
return (
<>
{
this._node = node;
}}
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
index d3dc58d5c..b460c1b4f 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
@@ -1,6 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
+ div.title {
+ color: var(--color-tab-inactive);
+ }
table {
width: 100%;
border-collapse: collapse;
@@ -19,14 +22,12 @@ const Wrapper = styled.div`
}
td {
padding: 6px 10px;
+ }
+ }
- &:nth-child(1) {
- width: 30%;
- }
-
- &:nth-child(3) {
- width: 70px;
- }
+ td {
+ &:nth-child(1) {
+ padding: 0 0 0 8px;
}
}
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
index 54e3ee0b3..3f7f7ef01 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
@@ -1,21 +1,32 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
+import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { addQueryParam, updateQueryParam, deleteQueryParam } from 'providers/ReduxStore/slices/collections';
+import {
+ addQueryParam,
+ updateQueryParam,
+ deleteQueryParam,
+ moveQueryParam,
+ updatePathParam
+} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
-import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import Table from 'components/Table/index';
+import ReorderTable from 'components/ReorderTable';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
+ const queryParams = params.filter((param) => param.type === 'query');
+ const pathParams = params.filter((param) => param.type === 'path');
- const handleAddParam = () => {
+ const handleAddQueryParam = () => {
dispatch(
addQueryParam({
itemUid: item.uid,
@@ -26,34 +37,63 @@ const QueryParams = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleParamChange = (e, _param, type) => {
- const param = cloneDeep(_param);
- switch (type) {
+ const handleQueryParamChange = (e, data, key) => {
+ let value;
+
+ switch (key) {
case 'name': {
- param.name = e.target.value;
+ value = e.target.value;
break;
}
case 'value': {
- param.value = e.target.value;
+ value = e.target.value;
break;
}
case 'enabled': {
- param.enabled = e.target.checked;
+ value = e.target.checked;
break;
}
}
+ let queryParam = cloneDeep(data);
+
+ if (queryParam[key] === value) {
+ return;
+ }
+
+ queryParam[key] = value;
+
dispatch(
updateQueryParam({
- param,
+ queryParam,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
- const handleRemoveParam = (param) => {
+ const handlePathParamChange = (e, data) => {
+ let value = e.target.value;
+
+ let pathParam = cloneDeep(data);
+
+ if (pathParam['value'] === value) {
+ return;
+ }
+
+ pathParam['value'] = value;
+
+ dispatch(
+ updatePathParam({
+ pathParam,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleRemoveQueryParam = (param) => {
dispatch(
deleteQueryParam({
paramUid: param.uid,
@@ -63,22 +103,32 @@ const QueryParams = ({ item, collection }) => {
);
};
+ const handleQueryParamDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveQueryParam({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
-
- Name
- Value
-
-
-
-
- {params && params.length
- ? params.map((param, index) => {
- return (
-
-
+
+
+
Query
+
+
+ {queryParams && queryParams.length
+ ? queryParams.map((param, index) => (
+
+
{
spellCheck="false"
value={param.name}
className="mousetrap"
- onChange={(e) => handleParamChange(e, param, 'name')}
+ onChange={(e) => handleQueryParamChange(e, param, 'name')}
/>
@@ -95,44 +145,100 @@ const QueryParams = ({ item, collection }) => {
value={param.value}
theme={storedTheme}
onSave={onSave}
- onChange={(newValue) =>
- handleParamChange(
- {
- target: {
- value: newValue
- }
- },
- param,
- 'value'
- )
- }
+ onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={handleRun}
collection={collection}
+ variablesAutocomplete={true}
/>
-
- );
- })
- : null}
-
-
-
- + Add Param
-
+ ))
+ : null}
+
+
+
+
+ + Add Param
+
+
+
Path
+
+
+ Path variables are automatically added whenever the
+ :name
+ template is used in the URL. For example:
+
+ https://example.com/v1/users/:id
+
+
+
+
+
+ {!(pathParams && pathParams.length) ?
: null}
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js
index 2308dec4f..cca562025 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js
@@ -33,18 +33,18 @@ const Wrapper = styled.div`
top: 1px;
}
- .tooltip {
+ .infotip {
position: relative;
display: inline-block;
cursor: pointer;
}
- .tooltip:hover .tooltiptext {
+ .infotip:hover .infotiptext {
visibility: visible;
opacity: 1;
}
- .tooltiptext {
+ .infotiptext {
visibility: hidden;
width: auto;
background-color: ${(props) => props.theme.requestTabs.active.bg};
@@ -62,7 +62,7 @@ const Wrapper = styled.div`
white-space: nowrap;
}
- .tooltiptext::after {
+ .infotiptext::after {
content: '';
position: absolute;
top: 100%;
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
index 4989ac80d..321ed4fd5 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
@@ -1,14 +1,16 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
-import { IconDeviceFloppy, IconArrowRight } from '@tabler/icons';
+import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper';
+import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index';
+import toast from 'react-hot-toast';
const QueryUrl = ({ item, collection, handleRun }) => {
const { theme, storedTheme } = useTheme();
@@ -17,26 +19,43 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
+ const editorRef = useRef(null);
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
+ const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
useEffect(() => {
const el = document.querySelector('.method-selector-container');
setMethodSelectorWidth(el.offsetWidth);
}, [method]);
- const onSave = () => {
+ const onSave = (finalValue) => {
dispatch(saveRequest(item.uid, collection.uid));
};
const onUrlChange = (value) => {
+ if (!editorRef.current?.editor) return;
+ const editor = editorRef.current.editor;
+ const cursor = editor.getCursor();
+
+ const finalUrl = value?.trim() ?? value;
+
dispatch(
requestUrlChanged({
itemUid: item.uid,
collectionUid: collection.uid,
- url: value && typeof value === 'string' ? value.trim() : value
+ url: finalUrl
})
);
+
+ // Restore cursor position only if URL was trimmed
+ if (finalUrl !== value) {
+ setTimeout(() => {
+ if (editor) {
+ editor.setCursor(cursor);
+ }
+ }, 0);
+ }
};
const onMethodSelect = (verb) => {
@@ -49,6 +68,15 @@ const QueryUrl = ({ item, collection, handleRun }) => {
);
};
+ const handleGenerateCode = (e) => {
+ e.stopPropagation();
+ if (item?.request?.url !== '' || (item.draft?.request?.url !== undefined && item.draft?.request?.url !== '')) {
+ setGenerateCodeItemModalOpen(true);
+ } else {
+ toast.error('URL is required');
+ }
+ };
+
return (
@@ -63,16 +91,35 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}}
>
onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
collection={collection}
+ highlightPathParams={true}
+ item={item}
/>
{
+ handleGenerateCode(e);
+ }}
+ >
+
+
+ Generate Code
+
+
+
{
e.stopPropagation();
if (!item.draft) return;
@@ -85,13 +132,16 @@ const QueryUrl = ({ item, collection, handleRun }) => {
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
-
+
Save ({saveShortcut})
+ {generateCodeItemModalOpen && (
+ setGenerateCodeItemModalOpen(false)} />
+ )}
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
index ba04f3c78..db73597df 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
@@ -9,7 +9,6 @@ import StyledWrapper from './StyledWrapper';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
-import { parse, stringify } from 'lossless-json';
import xmlFormat from 'xml-formatter';
const RequestBodyMode = ({ item, collection }) => {
@@ -129,6 +128,15 @@ const RequestBodyMode = ({ item, collection }) => {
SPARQL
Other
+ {
+ dropdownTippyRef.current.hide();
+ onModeChange('file');
+ }}
+ >
+ File / Binary
+
{
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestBody/StyledWrapper.js
index 83ebd8140..42da81d61 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/StyledWrapper.js
@@ -1,10 +1,6 @@
import styled from 'styled-components';
const Wrapper = styled.div`
- div.CodeMirror {
- /* todo: find a better way */
- height: calc(100vh - 220px);
- }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
index b776351d7..a0cc8729e 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import FileBody from '../FileBody/index';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -48,18 +49,25 @@ const RequestBody = ({ item, collection }) => {
);
}
+ if (bodyMode === 'file') {
+ return
;
+ }
+
if (bodyMode === 'formUrlEncoded') {
return
;
}
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
index 9f723cb81..5b787e8bb 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
@@ -19,15 +19,7 @@ const Wrapper = styled.div`
}
td {
padding: 6px 10px;
-
- &:nth-child(1) {
- width: 30%;
}
-
- &:nth-child(3) {
- width: 70px;
- }
- }
}
.btn-add-header {
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
index 445505c07..d88318017 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
@@ -4,11 +4,14 @@ import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { addRequestHeader, updateRequestHeader, deleteRequestHeader } from 'providers/ReduxStore/slices/collections';
+import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
+import Table from 'components/Table/index';
+import ReorderTable from 'components/ReorderTable/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
@@ -62,22 +65,31 @@ const RequestHeaders = ({ item, collection }) => {
);
};
+ const handleHeaderDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveRequestHeader({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
- Name
- Value
-
-
-
-
- {headers && headers.length
+
+
+ {headers && headers.length
? headers.map((header) => {
return (
-
-
+
+
{
)
}
onRun={handleRun}
+ autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
+ item={item}
/>
@@ -137,8 +151,8 @@ const RequestHeaders = ({ item, collection }) => {
);
})
: null}
-
-
+
+
+ Add Header
diff --git a/packages/bruno-app/src/components/RequestPane/SaveRequest/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/SaveRequest/StyledWrapper.js
deleted file mode 100644
index 422e234d4..000000000
--- a/packages/bruno-app/src/components/RequestPane/SaveRequest/StyledWrapper.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- .folder-list {
- border: 1px solid #ccc;
- border-radius: 5px;
-
- .folder-name {
- padding-block: 8px;
- padding-inline: 12px;
- cursor: pointer;
- &:hover {
- background-color: #e8e8e8;
- }
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/SaveRequest/index.js b/packages/bruno-app/src/components/RequestPane/SaveRequest/index.js
deleted file mode 100644
index 4b119f853..000000000
--- a/packages/bruno-app/src/components/RequestPane/SaveRequest/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { faFolder } from '@fortawesome/free-solid-svg-icons';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import StyledWrapper from './StyledWrapper';
-import Modal from 'components//Modal';
-
-const SaveRequest = ({ items, onClose }) => {
- const [showFolders, setShowFolders] = useState([]);
-
- useEffect(() => {
- setShowFolders(items || []);
- }, [items]);
-
- const handleFolderClick = (folder) => {
- let subFolders = [];
- if (folder.items && folder.items.length) {
- for (let item of folder.items) {
- if (item.items) {
- subFolders.push(item);
- }
- }
-
- if (subFolders.length) {
- setShowFolders(subFolders);
- }
- }
- };
-
- return (
-
-
- Select a folder to save request:
-
- {showFolders && showFolders.length
- ? showFolders.map((folder) => (
-
handleFolderClick(folder)}>
-
- {folder.name}
-
- ))
- : null}
-
-
-
- );
-};
-
-export default SaveRequest;
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index 935b52ede..ec4f4df95 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -40,26 +40,28 @@ const Script = ({ item, collection }) => {
return (
-
-
Pre Request
+
-
-
Post Response
+
+
Post Response
{
const dispatch = useDispatch();
@@ -28,18 +27,17 @@ const Tests = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
-
-
-
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/StyledWrapper.js
index efacc8288..c8ec78387 100644
--- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/StyledWrapper.js
@@ -19,16 +19,8 @@ const Wrapper = styled.div`
}
td {
padding: 6px 10px;
-
- &:nth-child(1) {
- width: 30%;
- }
-
- &:nth-child(3) {
- width: 70px;
}
}
- }
.btn-add-var {
font-size: 0.8125rem;
@@ -38,7 +30,8 @@ const Wrapper = styled.div`
width: 100%;
border: solid 1px transparent;
outline: none !important;
- background-color: inherit;
+ color: ${(props) => props.theme.table.input.color};
+ background: transparent;
&:focus {
outline: none !important;
diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
index 01cf0f340..cd3f83797 100644
--- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
@@ -3,13 +3,15 @@ import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { addVar, updateVar, deleteVar } from 'providers/ReduxStore/slices/collections';
+import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
-import Tooltip from 'components/Tooltip';
+import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
+import Table from 'components/Table/index';
+import ReorderTable from 'components/ReorderTable/index';
const VarsTable = ({ item, collection, vars, varType }) => {
const dispatch = useDispatch();
@@ -73,36 +75,41 @@ const VarsTable = ({ item, collection, vars, varType }) => {
);
};
+ const handleVarDrag = ({ updateReorderedItem }) => {
+ dispatch(
+ moveVar({
+ type: varType,
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ updateReorderedItem
+ })
+ );
+ };
+
return (
-
-
-
- Name
- {varType === 'request' ? (
-
-
- Value
-
-
-
- ) : (
-
-
- Expr
-
-
-
- )}
-
-
-
-
- {vars && vars.length
+
+ Value
+
+ ) : (
+
+ Expr
+
+
+ ), accessor: 'value', width: '46%' },
+ { name: '', accessor: '', width: '14%' }
+ ]}
+ >
+
+ {vars && vars.length
? vars.map((_var) => {
return (
-
-
+
+
{
}
onRun={handleRun}
collection={collection}
+ item={item}
/>
@@ -152,8 +160,8 @@ const VarsTable = ({ item, collection, vars, varType }) => {
);
})
: null}
-
-
+
+
+ Add
diff --git a/packages/bruno-app/src/components/RequestPane/Vars/index.js b/packages/bruno-app/src/components/RequestPane/Vars/index.js
index 500ebb25b..eb292e9c2 100644
--- a/packages/bruno-app/src/components/RequestPane/Vars/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Vars/index.js
@@ -9,11 +9,11 @@ const Vars = ({ item, collection }) => {
return (
-
+
-
+
diff --git a/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js
new file mode 100644
index 000000000..eff188890
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js
@@ -0,0 +1,42 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { useDispatch } from 'react-redux';
+
+const FolderNotFound = ({ folderUid }) => {
+ const dispatch = useDispatch();
+ const [showErrorMessage, setShowErrorMessage] = useState(false);
+
+ const closeTab = useCallback(() => {
+ dispatch(
+ closeTabs({
+ tabUids: [folderUid]
+ })
+ );
+ }, [dispatch, folderUid]);
+
+ useEffect(() => {
+ setTimeout(() => {
+ setShowErrorMessage(true);
+ }, 300);
+ }, []);
+
+ if (!showErrorMessage) {
+ return null;
+ }
+
+ return (
+
+
+
Folder no longer exists.
+
+ This can happen when the folder was renamed or deleted on your filesystem.
+
+
+
+ Close Tab
+
+
+ );
+};
+
+export default FolderNotFound;
\ No newline at end of file
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/RequestNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
index db0e45e41..cb62ac8a0 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
@@ -30,7 +30,7 @@ const RequestNotFound = ({ itemUid }) => {
return (
-
+
Request no longer exists.
This can happen when the .bru file associated with this request was deleted on your filesystem.
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..7908dfc09
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js
@@ -0,0 +1,83 @@
+import { IconLoader2, IconFile, IconAlertTriangle } 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
+
+
+
+
+
+
+
Path:
+
{item?.pathname}
+
+
+
+
Size:
+
{item?.size?.toFixed?.(2)} MB
+
+
+ {!item?.error && (
+
+
+
+ The request wasn't loaded due to its large size. Please try again with the following options:
+
+
+
+ Load in background
+
+
(Runs in background)
+
+
+
+ Force load
+
+
(May cause the app to freeze temporarily while it runs)
+
+
+ )}
+
+ {item?.loading && (
+ <>
+
+
+
+ Loading...
+
+ >
+ )}
+
+
+
+
+}
+
+export default RequestNotLoaded;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
index 64caa5821..ec0a03217 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
@@ -10,6 +10,7 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
width: 10px;
+ min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index f719eb0f3..5f53a5e02 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -18,6 +18,14 @@ import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
+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';
+import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -30,11 +38,30 @@ const RequestTabPanel = () => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const collections = useSelector((state) => state.collections.collections);
- const screenWidth = useSelector((state) => state.app.screenWidth);
-
- let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
+ const _collections = useSelector((state) => state.collections.collections);
+
+ // merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
+ let collections = produce(_collections, (draft) => {
+ let collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
+
+ if (collection) {
+ // add selected global env variables to the collection object
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({
+ globalEnvironments,
+ activeGlobalEnvironmentUid
+ });
+ const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid });
+ collection.globalEnvironmentVariables = globalEnvironmentVariables;
+ collection.globalEnvSecrets = globalEnvSecrets;
+ }
+ });
+
+ let collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
+
+ const screenWidth = useSelector((state) => state.app.screenWidth);
+ let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 so that request pane is relatively smaller
@@ -115,7 +142,6 @@ const RequestTabPanel = () => {
return
An error occurred!
;
}
- let collection = find(collections, (c) => c.uid === focusedTab.collectionUid);
if (!collection || !collection.uid) {
return
Collection not found!
;
}
@@ -132,11 +158,36 @@ const RequestTabPanel = () => {
return
;
}
+ if (focusedTab.type === 'collection-overview') {
+ return
;
+ }
+
+ if (focusedTab.type === 'folder-settings') {
+ const folder = findItemInCollection(collection, focusedTab.folderUid);
+ if (!folder) {
+ return
;
+ }
+
+ return
;
+ }
+
+ if (focusedTab.type === 'security-settings') {
+ return
;
+ }
+
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
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)} />, {
@@ -153,10 +204,9 @@ const RequestTabPanel = () => {
{item.type === 'graphql-request' ? (
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js
index ec278887d..39cd89e4c 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js
@@ -2,4 +2,4 @@ import styled from 'styled-components';
const StyledWrapper = styled.div``;
-export default StyledWrapper;
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
index ba77d47c9..447523fdb 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
@@ -2,9 +2,12 @@ import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
+import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
+import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
+import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
@@ -32,7 +35,7 @@ const CollectionToolBar = ({ collection }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
- uid: uuid(),
+ uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
@@ -44,17 +47,29 @@ const CollectionToolBar = ({ collection }) => {
- {collection.name}
+ {collection?.name}
-
+
-
+
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/CloseTabIcon.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/CloseTabIcon.js
new file mode 100644
index 000000000..de2aa845d
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/CloseTabIcon.js
@@ -0,0 +1,10 @@
+const CloseTabIcon = () => (
+
+
+
+);
+
+export default CloseTabIcon;
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/DraftTabIcon.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/DraftTabIcon.js
new file mode 100644
index 000000000..ab097c536
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/DraftTabIcon.js
@@ -0,0 +1,15 @@
+const DraftTabIcon = () => (
+
+
+
+);
+
+export default DraftTabIcon;
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
index 7bf45446e..220f12200 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { IconAlertTriangle } from '@tabler/icons';
+import CloseTabIcon from './CloseTabIcon';
const RequestTabNotFound = ({ handleCloseClick }) => {
const [showErrorMessage, setShowErrorMessage] = useState(false);
@@ -28,12 +29,7 @@ const RequestTabNotFound = ({ handleCloseClick }) => {
) : null}
handleCloseClick(e)}>
-
-
-
+
>
);
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
index aebc3db75..b895c10fe 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
@@ -1,22 +1,47 @@
import React from 'react';
-import { IconVariable, IconSettings, IconRun } from '@tabler/icons';
+import CloseTabIcon from './CloseTabIcon';
+import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
-const SpecialTab = ({ handleCloseClick, type }) => {
- const getTabInfo = (type) => {
+const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
+ const getTabInfo = (type, tabName) => {
switch (type) {
case 'collection-settings': {
+ return (
+
+
+ Collection
+
+ );
+ }
+ case 'collection-overview': {
return (
<>
-
Collection
+
Collection
>
);
}
+ case 'security-settings': {
+ return (
+ <>
+
+
Security
+ >
+ )
+ }
+ case 'folder-settings': {
+ return (
+
+
+ {tabName || 'Folder'}
+
+ );
+ }
case 'variables': {
return (
<>
-
Variables
+
Variables
>
);
}
@@ -24,7 +49,7 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
-
Runner
+
Runner
>
);
}
@@ -33,14 +58,9 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
-
{getTabInfo(type)}
+
{getTabInfo(type, tabName)}
handleCloseClick(e)}>
-
-
-
+
>
);
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
index a067e9876..816b00e25 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
@@ -1,6 +1,6 @@
-import React, { useState } from 'react';
+import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
-import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
@@ -12,12 +12,21 @@ import ConfirmRequestClose from './ConfirmRequestClose';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
+import Dropdown from 'components/Dropdown';
+import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
+import NewRequest from 'components/Sidebar/NewRequest/index';
+import CloseTabIcon from './CloseTabIcon';
+import DraftTabIcon from './DraftTabIcon';
+import { flattenItems } from 'utils/collections/index';
-const RequestTab = ({ tab, collection }) => {
+const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false);
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -28,11 +37,25 @@ const RequestTab = ({ tab, collection }) => {
);
};
+ const handleRightClick = (_event) => {
+ const menuDropdown = dropdownTippyRef.current;
+ if (!menuDropdown) {
+ return;
+ }
+
+ if (menuDropdown.state.isShown) {
+ menuDropdown.hide();
+ } else {
+ menuDropdown.show();
+ }
+ };
+
const handleMouseUp = (e) => {
if (e.button === 1) {
- e.stopPropagation();
e.preventDefault();
+ e.stopPropagation();
+ // Close the tab
dispatch(
closeTabs({
tabUids: [tab.uid]
@@ -43,48 +66,23 @@ const RequestTab = ({ tab, collection }) => {
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
-
- let color = '';
- method = method.toLocaleLowerCase();
-
- switch (method) {
- case 'get': {
- color = theme.request.methods.get;
- break;
- }
- case 'post': {
- color = theme.request.methods.post;
- break;
- }
- case 'put': {
- color = theme.request.methods.put;
- break;
- }
- case 'delete': {
- color = theme.request.methods.delete;
- break;
- }
- case 'patch': {
- color = theme.request.methods.patch;
- break;
- }
- case 'options': {
- color = theme.request.methods.options;
- break;
- }
- case 'head': {
- color = theme.request.methods.head;
- break;
- }
- }
-
- return color;
+ return theme.request.methods[method.toLocaleLowerCase()];
};
- if (['collection-settings', 'variables', 'collection-runner'].includes(tab.type)) {
+ const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
+ if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
-
-
+
+ {tab.type === 'folder-settings' && !folder ? (
+
+ ) : tab.type === 'folder-settings' ? (
+ dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
+ ) : (
+ dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
+ )}
);
}
@@ -93,7 +91,17 @@ const RequestTab = ({ tab, collection }) => {
if (!item) {
return (
-
+ {
+ if (e.button === 1) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ dispatch(closeTabs({ tabUids: [tab.uid] }));
+ }
+ }}
+ >
);
@@ -138,7 +146,9 @@ const RequestTab = ({ tab, collection }) => {
/>
)}
dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);
@@ -155,6 +165,15 @@ const RequestTab = ({ tab, collection }) => {
{item.name}
+
{
}}
>
{!item.draft ? (
-
-
-
+
) : (
-
-
-
+
)}
);
};
+function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, collection, dropdownTippyRef, dispatch }) {
+ const [showCloneRequestModal, setShowCloneRequestModal] = useState(false);
+ const [showAddNewRequestModal, setShowAddNewRequestModal] = useState(false);
+
+ const totalTabs = collectionRequestTabs.length || 0;
+ const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
+ const currentTabItem = findItemInCollection(collection, currentTabUid);
+
+ const hasLeftTabs = tabIndex !== 0;
+ const hasRightTabs = totalTabs > tabIndex + 1;
+ const hasOtherTabs = totalTabs > 1;
+
+ async function handleCloseTab(event, tabUid) {
+ event.stopPropagation();
+ dropdownTippyRef.current.hide();
+
+ if (!tabUid) {
+ return;
+ }
+
+ try {
+ const item = findItemInCollection(collection, tabUid);
+ // silently save unsaved changes before closing the tab
+ if (item.draft) {
+ await dispatch(saveRequest(item.uid, collection.uid, true));
+ }
+
+ dispatch(closeTabs({ tabUids: [tabUid] }));
+ } catch (err) {}
+ }
+
+ function handleCloseOtherTabs(event) {
+ dropdownTippyRef.current.hide();
+
+ const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
+ otherTabs.forEach((tab) => handleCloseTab(event, tab.uid));
+ }
+
+ function handleCloseTabsToTheLeft(event) {
+ dropdownTippyRef.current.hide();
+
+ const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
+ leftTabs.forEach((tab) => handleCloseTab(event, tab.uid));
+ }
+
+ function handleCloseTabsToTheRight(event) {
+ dropdownTippyRef.current.hide();
+
+ const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
+ rightTabs.forEach((tab) => handleCloseTab(event, tab.uid));
+ }
+
+ function handleCloseSavedTabs(event) {
+ event.stopPropagation();
+
+ const items = flattenItems(collection?.items);
+ const savedTabs = items?.filter?.((item) => !item.draft);
+ const savedTabIds = savedTabs?.map((item) => item.uid) || [];
+ dispatch(closeTabs({ tabUids: savedTabIds }));
+ }
+
+ function handleCloseAllTabs(event) {
+ collectionRequestTabs.forEach((tab) => handleCloseTab(event, tab.uid));
+ }
+
+ return (
+
+ {showAddNewRequestModal && (
+ setShowAddNewRequestModal(false)} />
+ )}
+
+ {showCloneRequestModal && (
+ setShowCloneRequestModal(false)}
+ />
+ )}
+
+ } placement="bottom-start">
+ {
+ dropdownTippyRef.current.hide();
+ setShowAddNewRequestModal(true);
+ }}
+ >
+ New Request
+
+ {
+ dropdownTippyRef.current.hide();
+ setShowCloneRequestModal(true);
+ }}
+ >
+ Clone Request
+
+ handleCloseTab(e, currentTabUid)}>
+ Close
+
+
+ Close Others
+
+
+ Close to the Left
+
+
+ Close to the Right
+
+
+ Close Saved
+
+
+ Close All
+
+
+
+ );
+}
+
export default RequestTab;
diff --git a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
index ec76ec5b5..93829cca9 100644
--- a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
@@ -7,13 +7,14 @@ const Wrapper = styled.div`
padding: 0;
margin: 0;
display: flex;
- position: relative;
overflow: scroll;
&::-webkit-scrollbar {
display: none;
}
+ scrollbar-width: none;
+
li {
display: inline-flex;
max-width: 150px;
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index 3063771e8..d50d528b3 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -75,12 +75,11 @@ const RequestTabs = () => {
'has-chevrons': showChevrons
});
};
-
// Todo: Must support ephemeral requests
return (
{newRequestModalOpen && (
- setNewRequestModalOpen(false)} />
+ setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
@@ -111,7 +110,14 @@ const RequestTabs = () => {
role="tab"
onClick={() => handleClick(tab)}
>
-
+
);
})
diff --git a/packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js
new file mode 100644
index 000000000..8c32a8bab
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js
@@ -0,0 +1,8 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ font-size: 0.8125rem;
+ color: ${(props) => props.theme.requestTabPanel.responseStatus};
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js
new file mode 100644
index 000000000..18704cc41
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import StyledWrapper from './StyledWrapper';
+import { clearRequestTimeline } from 'providers/ReduxStore/slices/collections/index';
+
+const ClearTimeline = ({ collection, item }) => {
+ const dispatch = useDispatch();
+
+ const clearResponse = () =>
+ dispatch(
+ clearRequestTimeline({
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+
+ return (
+
+
+ Clear Timeline
+
+
+ );
+};
+
+export default ClearTimeline;
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
index a341acdc2..045a9dcc3 100644
--- a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
@@ -3,6 +3,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
position: absolute;
height: 100%;
+ width: calc(100% - 0.75rem);
z-index: 1;
background-color: ${(props) => props.theme.requestTabPanel.responseOverlayBg};
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
index b203053fb..8ede2d6ec 100644
--- a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
@@ -13,11 +13,11 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
};
return (
-
+
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
index a07acc95f..bd52c410a 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
@@ -2,7 +2,7 @@ import { IconFilter, IconX } from '@tabler/icons';
import React, { useMemo } from 'react';
import { useRef } from 'react';
import { useState } from 'react';
-import { Tooltip as ReactTooltip } from 'react-tooltip';
+import { Tooltip as ReactInfotip } from 'react-tooltip';
const QueryResultFilter = ({ filter, onChange, mode }) => {
const inputRef = useRef(null);
@@ -19,7 +19,7 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
}
};
- const tooltipText = useMemo(() => {
+ const infotipText = useMemo(() => {
if (mode.includes('json')) {
return 'Filter with JSONPath';
}
@@ -46,10 +46,10 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
return (
- {tooltipText && !isExpanded &&
}
+ {infotipText && !isExpanded &&
}
{
autoCapitalize="off"
spellCheck="false"
className={`block ml-14 p-2 py-1 sm:text-sm transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${
- isExpanded ? 'w-full opacity-100' : 'w-[0] opacity-0'
+ isExpanded ? 'w-full opacity-100 pointer-events-auto' : 'w-[0] opacity-0'
}`}
onChange={onChange}
/>
-
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
index 13b280320..d4fbee07e 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
@@ -1,12 +1,41 @@
+import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
-import { useState } from 'react';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
+import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
+GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.min.mjs';
+import ReactPlayer from 'react-player';
+
+const VideoPreview = React.memo(({ contentType, dataBuffer }) => {
+ const [videoUrl, setVideoUrl] = useState(null);
+
+ useEffect(() => {
+ const videoType = contentType.split(';')[0];
+ const byteArray = Buffer.from(dataBuffer, 'base64');
+ const blob = new Blob([byteArray], { type: videoType });
+ const url = URL.createObjectURL(blob);
+ setVideoUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }, [contentType, dataBuffer]);
+
+ if (!videoUrl) return
Loading video...
;
+
+ return (
+
console.error('Error loading video:', e)}
+ />
+ );
+});
const QueryResultPreview = ({
previewTab,
@@ -29,7 +58,7 @@ const QueryResultPreview = ({
setNumPages(numPages);
}
// Fail safe, so we don't render anything with an invalid tab
- if (!allowedPreviewModes.includes(previewTab)) {
+ if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
return null;
}
@@ -40,7 +69,7 @@ const QueryResultPreview = ({
dispatch(sendRequest(item, collection.uid));
};
- switch (previewTab) {
+ switch (previewTab?.mode) {
case 'preview-web': {
const webViewSrc = data.replace('', ` `);
return (
@@ -71,9 +100,7 @@ const QueryResultPreview = ({
);
}
case 'preview-video': {
- return (
-
- );
+ return ;
}
default:
case 'raw': {
@@ -81,6 +108,7 @@ const QueryResultPreview = ({
{
- if (data === undefined) {
+const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
+ if (data === undefined || !dataBuffer || !mode) {
return '';
}
+ // TODO: We need a better way to get the raw response-data here instead
+ // of using this dataBuffer param.
+ // Also, we only need the raw response-data and content-type to show the preview.
+ const rawData = iconv.decode(
+ Buffer.from(dataBuffer, "base64"),
+ iconv.encodingExists(encoding) ? encoding : "utf-8"
+ );
+
if (mode.includes('json')) {
+ try {
+ JSON.parse(rawData);
+ } catch (error) {
+ // If the response content-type is JSON and it fails parsing, its an invalid JSON.
+ // In that case, just show the response as it is in the preview.
+ return rawData;
+ }
+
if (filter) {
try {
data = JSONPath({ path: filter, json: data });
} catch (e) {
- console.warn('Could not filter with JSONPath.', e.message);
+ console.warn('Could not apply JSONPath filter:', e.message);
}
}
@@ -35,7 +50,6 @@ const formatResponse = (data, mode, filter) => {
if (typeof parsed === 'string') {
return parsed;
}
-
return safeStringifyJSON(parsed, true);
}
@@ -43,14 +57,31 @@ const formatResponse = (data, mode, filter) => {
return data;
}
- return safeStringifyJSON(data);
+ return safeStringifyJSON(data, true);
+};
+
+const formatErrorMessage = (error) => {
+ if (!error) return 'Something went wrong';
+
+ const remoteMethodError = "Error invoking remote method 'send-http-request':";
+
+ if (error?.includes(remoteMethodError)) {
+ const parts = error.split(remoteMethodError);
+ return parts[1]?.trim() || error;
+ }
+
+ return error;
};
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
- const formattedData = formatResponse(data, mode, filter);
+ const responseEncoding = getEncoding(headers);
+ const formattedData = useMemo(
+ () => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
+ [data, dataBuffer, responseEncoding, mode, filter]
+ );
const { displayedTheme } = useTheme();
const debouncedResultFilterOnChange = debounce((e) => {
@@ -59,18 +90,20 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const allowedPreviewModes = useMemo(() => {
// Always show raw
- const allowedPreviewModes = ['raw'];
+ const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
- if (mode.includes('html') && typeof data === 'string') {
- allowedPreviewModes.unshift('preview-web');
+ if (!mode || !contentType) return allowedPreviewModes;
+
+ if (mode?.includes('html') && typeof data === 'string') {
+ allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
- allowedPreviewModes.unshift('preview-image');
+ allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
} else if (contentType.includes('pdf')) {
- allowedPreviewModes.unshift('preview-pdf');
+ allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
} else if (contentType.includes('audio')) {
- allowedPreviewModes.unshift('preview-audio');
+ allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
} else if (contentType.includes('video')) {
- allowedPreviewModes.unshift('preview-video');
+ allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
}
return allowedPreviewModes;
@@ -79,7 +112,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
// Ensure the active Tab is always allowed
useEffect(() => {
- if (!allowedPreviewModes.includes(previewTab)) {
+ if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
setPreviewTab(allowedPreviewModes[0]);
}
}, [previewTab, allowedPreviewModes]);
@@ -91,21 +124,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return allowedPreviewModes.map((previewMode) => (
setPreviewTab(previewMode)}
- key={previewMode}
+ key={previewMode?.uid}
>
- {previewMode.replace(/-(.*)/, ' ')}
+ {previewMode?.name}
));
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
+ const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
@@ -114,7 +151,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
{error ? (
-
{error}
+ {hasScriptError ? null :
{formatErrorMessage(error)}
}
{error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
@@ -124,24 +161,26 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
) : null}
) : (
- <>
-
- {queryFilterEnabled && (
-
- )}
- >
+
+
+
+ {queryFilterEnabled && (
+
+ )}
+
+
)}
);
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
index 7c183b0a6..dae5bad4d 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
@@ -11,7 +11,7 @@ const ResponseSave = ({ item }) => {
const saveResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
- .invoke('renderer:save-response-to-file', response, item.requestSent.url)
+ .invoke('renderer:save-response-to-file', response, item?.requestSent?.url)
.then(resolve)
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js
new file mode 100644
index 000000000..ef46d4361
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js
@@ -0,0 +1,110 @@
+import '@testing-library/jest-dom';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ThemeProvider } from 'styled-components';
+import ResponseSize from './index';
+
+// Create minimal theme with only the properties needed for the component
+const theme = {
+ requestTabPanel: {
+ responseStatus: '#666'
+ }
+};
+
+// Wrap component with theme provider for styled-components
+const renderWithTheme = (component) => {
+ return render(
+
+ {component}
+
+ );
+};
+
+describe('ResponseSize', () => {
+ describe('Invalid or excluded size values', () => {
+ it('should not render when size is undefined', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is null', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is NaN', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is Infinity', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is -Infinity', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is a string', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is an object', () => {
+ const { container } = renderWithTheme(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
+
+ describe('Valid size values', () => {
+ it('should handle zero bytes', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/0B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^0B$/);
+ expect(element).toHaveAttribute('title', '0B');
+ });
+
+ it('should render bytes when size is less than 1024', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/500B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^500B$/);
+ expect(element).toHaveAttribute('title', '500B');
+ });
+
+ it('should handle exactly 1024 bytes as size', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/1024B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^1024B$/);
+ expect(element).toHaveAttribute('title', '1,024B');
+ });
+
+ it('should render kilobytes when size is greater than 1024', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/1\.46KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '1,500B');
+ });
+
+ it('should handle large size numbers', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/10\.0KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '10,240B');
+ });
+
+ it('should handle decimal size numbers', () => {
+ renderWithTheme(
);
+ const element = screen.getByText(/1\.10KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '1,126.5B');
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
index b956b0813..b1cff2157 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
@@ -2,14 +2,20 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
+
+ if (!Number.isFinite(size)) {
+ return null;
+ }
+
let sizeToDisplay = '';
+ // If size is greater than 1024 bytes, format as KB
if (size > 1024) {
- // size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
+ // If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}
diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js
new file mode 100644
index 000000000..020d5bd91
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js
@@ -0,0 +1,24 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .line {
+ white-space: pre-line;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-family: Inter, sans-serif !important;
+
+ .arrow {
+ opacity: 0.5;
+ }
+
+ &.request {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.response {
+ color: ${(props) => props.theme.colors.text.purple};
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js
new file mode 100644
index 000000000..592e0641b
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import forOwn from 'lodash/forOwn';
+import { safeStringifyJSON } from 'utils/common';
+import StyledWrapper from './StyledWrapper';
+
+const RunnerTimeline = ({ request, response }) => {
+ const requestHeaders = [];
+ const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
+
+ request = request || {};
+ response = response || {};
+
+ forOwn(request.headers, (value, key) => {
+ requestHeaders.push({
+ name: key,
+ value
+ });
+ });
+
+ let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
+
+ return (
+
+
+
+ {'>'} {request.method} {request.url}
+
+ {requestHeaders.map((h) => {
+ return (
+
+ {'>'} {h.name}: {h.value}
+
+ );
+ })}
+
+ {requestData ? (
+
+ {'>'} data{' '}
+ {requestData}
+
+ ) : null}
+
+
+
+
+ {'<'} {response.status} - {response.statusText}
+
+
+ {responseHeaders.map((h) => {
+ return (
+
+ {'<'} {h[0]}: {h[1]}
+
+ );
+ })}
+
+
+ );
+};
+
+export default RunnerTimeline;
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
new file mode 100644
index 000000000..c4a38e80f
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ border-left: 4px solid ${(props) => props.theme.colors.text.danger};
+ border-top: 1px solid transparent;
+ border-right: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ max-height: 200px;
+ min-height: 70px;
+ overflow-y: auto;
+ background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)'};
+
+ .error-icon-container {
+ margin-top: 0.125rem;
+ padding: 0.375rem;
+ border-radius: 9999px;
+ background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.8)' : 'rgba(240, 240, 240, 0.8)'};
+
+ svg {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .close-button {
+ opacity: 0.7;
+ transition: opacity 0.2s;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ svg {
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .error-title {
+ font-weight: 600;
+ margin-bottom: 0.375rem;
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ .error-message {
+ font-family: monospace;
+ font-size: 0.6875rem;
+ line-height: 1.25rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ color: ${(props) => props.theme.text};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
new file mode 100644
index 000000000..4af07c587
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import { IconX } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+
+const ScriptError = ({ item, onClose }) => {
+ const preRequestError = item?.preRequestScriptErrorMessage;
+ const postResponseError = item?.postResponseScriptErrorMessage;
+
+ if (!preRequestError && !postResponseError) return null;
+
+ const errorMessage = preRequestError || postResponseError;
+ const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error';
+
+ return (
+
+
+
+
+ {errorTitle}
+
+
+ {errorMessage}
+
+
+
+
+
+
+
+ );
+};
+
+export default ScriptError;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
new file mode 100644
index 000000000..208a49ffd
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { IconAlertCircle } from '@tabler/icons';
+import ToolHint from 'components/ToolHint';
+
+const ScriptErrorIcon = ({ itemUid, onClick }) => {
+ const toolhintId = `script-error-icon-${itemUid}`;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default ScriptErrorIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js
new file mode 100644
index 000000000..a7049ad6b
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ padding-top: 20%;
+ width: 100%;
+ .send-icon {
+ color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js
new file mode 100644
index 000000000..684dc3c37
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { IconCircleOff } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const SkippedRequest = () => {
+ return (
+
+
+
+
+
+ Request skipped
+
+
+ );
+};
+
+export default SkippedRequest;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
index 13fa41142..5b029386e 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
@@ -1,6 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.text};
+
+ .test-summary {
+ transition: background-color 0.2s;
+ border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
+ color: ${(props) => props.theme.text};
+
+ &:hover {
+ background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
+ }
+ }
+
.test-success {
color: ${(props) => props.theme.colors.text.green};
}
@@ -9,9 +21,25 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
+ .test-success-count {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ .test-failure-count {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
+
+ .test-results-list {
+ transition: all 0.3s ease;
+ }
+
+ .dropdown-icon {
+ color: ${(props) => props.theme.sidebar.dropdownIcon.color};
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
index 074fac9e1..8157df2ee 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
@@ -1,63 +1,151 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
+import {
+ IconChevronDown,
+ IconChevronRight,
+ IconCircleCheck,
+ IconCircleX
+} from '@tabler/icons';
-const TestResults = ({ results, assertionResults }) => {
+const ResultIcon = ({ status }) => (
+
+ {status === 'pass' ? (
+
+ ) : (
+
+ )}
+
+);
+
+const ErrorMessage = ({ error }) => error && (
+ <>
+
+
+ {error}
+
+ >
+);
+
+const ResultItem = ({ result, type }) => (
+
+
+
+ {type === 'assertion'
+ ? `${result.lhsExpr}: ${result.rhsExpr}`
+ : result.description
+ }
+
+
+
+);
+
+const TestSection = ({
+ title,
+ results,
+ isExpanded,
+ onToggle,
+ type = 'test'
+}) => {
+ const passedResults = results.filter((result) => result.status === 'pass');
+ const failedResults = results.filter((result) => result.status === 'fail');
+
+ if (results.length === 0) return null;
+
+ return (
+
+
+
+ {isExpanded ?
+ :
+
+ }
+
+
+ {title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length}
+
+
+ {isExpanded && (
+
+ {results.map((result) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
- if (!results.length && !assertionResults.length) {
+ preRequestTestResults = preRequestTestResults || [];
+ postResponseTestResults = postResponseTestResults || [];
+
+ const [expandedSections, setExpandedSections] = useState({
+ preRequest: true,
+ tests: true,
+ postResponse: true,
+ assertions: true
+ });
+
+ useEffect(() => {
+ setExpandedSections({
+ preRequest: preRequestTestResults.length > 0,
+ tests: results.length > 0,
+ postResponse: postResponseTestResults.length > 0,
+ assertions: assertionResults.length > 0
+ });
+ }, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]);
+
+ const toggleSection = (section) => {
+ setExpandedSections({
+ ...expandedSections,
+ [section]: !expandedSections[section]
+ });
+ };
+
+ if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return
No tests found
;
}
- const passedTests = results.filter((result) => result.status === 'pass');
- const failedTests = results.filter((result) => result.status === 'fail');
-
- const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
- const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
-
return (
-
-
- Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
-
-
- {results.map((result) => (
-
- {result.status === 'pass' ? (
- ✔ {result.description}
- ) : (
- <>
- ✘ {result.description}
-
- {result.error}
- >
- )}
-
- ))}
-
+
+ toggleSection('preRequest')}
+ type="test"
+ />
-
- Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '}
- {failedAssertions.length}
-
-
- {assertionResults.map((result) => (
-
- {result.status === 'pass' ? (
-
- ✔ {result.lhsExpr}: {result.rhsExpr}
-
- ) : (
- <>
-
- ✘ {result.lhsExpr}: {result.rhsExpr}
-
-
- {result.error}
- >
- )}
-
- ))}
-
+ toggleSection('postResponse')}
+ type="test"
+ />
+
+ toggleSection('tests')}
+ type="test"
+ />
+
+ toggleSection('assertions')}
+ type="assertion"
+ />
);
};
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
index f894d1f76..51d6f94cc 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
@@ -1,9 +1,13 @@
import React from 'react';
+import { IconCircleCheck, IconCircleX } from '@tabler/icons';
-const TestResultsLabel = ({ results, assertionResults }) => {
+const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
- if (!results.length && !assertionResults.length) {
+ preRequestTestResults = preRequestTestResults || [];
+ postResponseTestResults = postResponseTestResults || [];
+
+ if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return 'Tests';
}
@@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => {
const numberOfAssertions = assertionResults.length;
const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length;
- const totalNumberOfTests = numberOfTests + numberOfAssertions;
- const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
+ const numberOfPreRequestTests = preRequestTestResults.length;
+ const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length;
+
+ const numberOfPostResponseTests = postResponseTestResults.length;
+ const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length;
+
+ const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests;
+ const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests;
return (
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
index 020d5bd91..4b7cb28a7 100644
--- a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
@@ -1,11 +1,109 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
+ .timeline-event {
+ padding: 8px 0 0 0;
+ cursor: pointer;
+ }
+
+ .timeline-event-content {
+ border-radius: 4px;
+ padding: 12px;
+ margin-top: 0.5rem;
+ }
+
+ .timeline-event-header {
+ color: ${(props) => props.theme.text};
+ }
+
+ .method-label {
+ font-weight: 600;
+ }
+
+ .status-code {
+ font-weight: 600;
+ }
+
+ .url-text {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+ }
+
+ .timestamp {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .meta-info {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .oauth-section {
+ .oauth-header {
+ display: flex;
+ align-items: center;
+ color: ${(props) => props.theme.text};
+ font-weight: 600;
+
+ span {
+ margin-left: 0.5rem;
+ }
+ }
+ }
+
+ .tabs-switcher {
+ border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
+ margin-bottom: 16px;
+
+ button {
+ position: relative;
+ padding: 8px 16px;
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color};
+ &:after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${(props) => props.theme.tabs.active.border};
+ }
+ }
+ }
+ }
+
+ .network-logs {
+ background: ${(props) => props.theme.codemirror.bg};
+ color: ${(props) => props.theme.text};
+ border-radius: 4px;
+ }
+
+ .oauth-request-item-content {
+ border-radius: 4px;
+ margin-top: 0.5rem;
+ }
+
+ .collapsible-section {
+ margin-bottom: 12px;
+
+ .section-header {
+ cursor: pointer;
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ }
+
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
- font-family: Inter, sans-serif !important;
+ font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
.arrow {
opacity: 0.5;
@@ -19,6 +117,35 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.purple};
}
}
+
+ .request-label {
+ font-size: 0.75rem;
+ padding: 2px 6px;
+ border-radius: 3px;
+ margin-left: 8px;
+ background: ${(props) => props.theme.requestTabs.bg};
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js
new file mode 100644
index 000000000..6f9d2f832
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js
@@ -0,0 +1,37 @@
+import QueryResult from "components/ResponsePane/QueryResult/index";
+import { useState } from "react";
+
+const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, width }) => {
+ const [isBodyCollapsed, toggleBody] = useState(true);
+ return (
+
+
toggleBody(!isBodyCollapsed)}>
+
+ {isBodyCollapsed ? '▼' : '▶'}
Body
+
+
+ {isBodyCollapsed && (
+
+ {data || dataBuffer ? (
+
+
+
+ ) : (
+
No Body found
+ )}
+
+ )}
+
+ )
+}
+
+export default BodyBlock;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js
new file mode 100644
index 000000000..bb8448f70
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js
@@ -0,0 +1,54 @@
+import { useState } from "react";
+
+const HeadersBlock = ({ headers, type }) => {
+ const [areHeadersCollapsed, toggleHeaders] = useState(true);
+
+ return (
+
+
toggleHeaders(!areHeadersCollapsed)}>
+
+ {areHeadersCollapsed ? '▼' : '▶'}
Headers
+ {headers && Object.keys(headers).length > 0 &&
+ ({Object.keys(headers).length})
+ }
+
+
+ {areHeadersCollapsed && (
+
+ {headers && Object.keys(headers).length > 0
+ ?
+ :
No Headers found
+ }
+
+ )}
+
+ )
+};
+
+const Headers = ({ headers, type }) => {
+ if (Array.isArray(headers)) {
+ return (
+
+ {headers.map((header, index) => (
+
+ {type === 'request' ? '>' : '<'} {header?.name}:
+ {String(header?.value)}
+
+ ))}
+
+ );
+ } else {
+ return (
+
+ {Object.entries(headers).map(([key, value], index) => (
+
+ {type === 'request' ? '>' : '<'} {key}:
+ {String(value)}
+
+ ))}
+
+ );
+ }
+};
+
+export default HeadersBlock;
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js
new file mode 100644
index 000000000..1e0b22d3a
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js
@@ -0,0 +1,19 @@
+const Method = ({ method }) => {
+ return (
+
+ {method?.toUpperCase()}
+
+ )
+}
+
+const methodColors = {
+ GET: 'text-green-500',
+ POST: 'text-blue-500',
+ PUT: 'text-yellow-500',
+ DELETE: 'text-red-500',
+ PATCH: 'text-purple-500',
+ OPTIONS: 'text-gray-500',
+ HEAD: 'text-gray-500',
+};
+
+export default Method;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js
new file mode 100644
index 000000000..8c0094120
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js
@@ -0,0 +1,26 @@
+const Status = ({ statusCode, statusText }) => {
+ return (
+
+ {statusCode}{' '}
+ {statusText || ''}
+
+ )
+}
+
+const statusColor = (statusCode) => {
+ if (statusCode >= 200 && statusCode < 300) {
+ return 'text-green-500';
+ } else if (statusCode >= 300 && statusCode < 400) {
+ return 'text-yellow-500';
+ } else if (statusCode >= 400 && statusCode < 600) {
+ return 'text-red-500';
+ } else {
+ return 'text-gray-500';
+ }
+};
+
+export default Status;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js
new file mode 100644
index 000000000..d4fd0ec07
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js
@@ -0,0 +1,36 @@
+import { useState, useEffect } from "react";
+
+const getRelativeTime = (date) => {
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+ const diff = (date - new Date()) / 1000;
+
+ const timeUnits = [
+ { unit: 'year', seconds: 31536000 },
+ { unit: 'month', seconds: 2592000 },
+ { unit: 'week', seconds: 604800 },
+ { unit: 'day', seconds: 86400 },
+ { unit: 'hour', seconds: 3600 },
+ { unit: 'minute', seconds: 60 },
+ { unit: 'second', seconds: 1 }
+ ];
+
+ for (const { unit, seconds } of timeUnits) {
+ if (Math.abs(diff) >= seconds || unit === 'second') {
+ return rtf.format(Math.round(diff / seconds), unit);
+ }
+ }
+};
+
+export const RelativeTime = ({ timestamp }) => {
+ const [relativeTime, setRelativeTime] = useState(getRelativeTime(new Date(timestamp)));
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setRelativeTime(getRelativeTime(new Date(timestamp)));
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [timestamp]);
+
+ return
{relativeTime} ;
+};
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js
new file mode 100644
index 000000000..25d704e63
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js
@@ -0,0 +1,54 @@
+const Network = ({ logs }) => {
+ return (
+
+
+ {logs.map((currentLog, index) => {
+ if (index > 0 && currentLog?.type === 'separator') {
+ return
;
+ }
+ const nextLog = logs[index + 1];
+ const isSameLogType = nextLog?.type === currentLog?.type;
+ return <>
+
+ {!isSameLogType &&
}
+ >;
+ })}
+
+
+ )
+}
+
+const NetworkLogsEntry = ({ entry }) => {
+ const { type, message } = entry;
+ let className = '';
+
+ switch (type) {
+ case 'request':
+ className = 'text-blue-500';
+ break;
+ case 'response':
+ className = 'text-green-500';
+ break;
+ case 'error':
+ className = 'text-red-500';
+ break;
+ case 'tls':
+ className = 'text-purple-500';
+ break;
+ case 'info':
+ className = 'text-yellow-500';
+ break;
+ default:
+ className = 'text-gray-400';
+ break;
+ }
+
+ return (
+
+ );
+};
+
+
+export default Network;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js
new file mode 100644
index 000000000..1cef8e9e5
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js
@@ -0,0 +1,41 @@
+import Headers from "../Common/Headers/index";
+import BodyBlock from "../Common/Body/index";
+
+const safeStringifyJSONIfNotString = (obj) => {
+ if (obj === null || obj === undefined) return '';
+
+ if (typeof obj === 'string') {
+ return obj;
+ }
+
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return '[Unserializable Object]';
+ }
+};
+
+
+const Request = ({ collection, request, item, width }) => {
+ let { url, headers, data, dataBuffer, error } = request || {};
+ if (!dataBuffer) {
+ dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
+ }
+
+ return (
+
+ {/* Method and URL */}
+
+
+ {/* Headers */}
+
+
+ {/* Body */}
+
+
+ )
+}
+
+export default Request;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js
new file mode 100644
index 000000000..c70825eb9
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js
@@ -0,0 +1,43 @@
+import BodyBlock from "../Common/Body/index";
+import Headers from "../Common/Headers/index";
+import Status from "../Common/Status/index";
+
+const safeStringifyJSONIfNotString = (obj) => {
+ if (obj === null || obj === undefined) return '';
+
+ if (typeof obj === 'string') {
+ return obj;
+ }
+
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return '[Unserializable Object]';
+ }
+};
+
+const Response = ({ collection, response, item, width }) => {
+ let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
+ if (!dataBuffer) {
+ dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
+ }
+
+ return (
+
+ {/* Status */}
+
+
+ {response.duration && {response.duration}ms }
+ {response.size && {response.size}B }
+
+
+ {/* Headers */}
+
+
+ {/* Body */}
+
+
+ )
+}
+
+export default Response;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js
new file mode 100644
index 000000000..92e5db8ec
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js
@@ -0,0 +1,81 @@
+import { useState } from "react";
+import Network from "./Network/index";
+import Request from "./Request/index";
+import Response from "./Response/index";
+import Method from "./Common/Method/index";
+import Status from "./Common/Status/index";
+import { RelativeTime } from "./Common/Time/index";
+
+const TimelineItem = ({ timestamp, request, response, item, collection, width, isOauth2 }) => {
+ const [isCollapsed, _toggleCollapse] = useState(false);
+ const [activeTab, setActiveTab] = useState('request');
+ const toggleCollapse = () => _toggleCollapse(prev => !prev);
+ const { method, status, statusCode, statusText, url = '' } = request || {};
+ const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {};
+ const showNetworkLogs = response.timeline && response.timeline.length > 0;
+
+ return (
+
+
+
+
+
+
+
+ {isOauth2 ?
[oauth2.0] : null}
+
[{new Date(timestamp).toISOString()}]
+
+
+
+
+
+
{url}
+
+ {isCollapsed && (
+ {/* Tabs */}
+
+ setActiveTab('request')}
+ >
+ Request
+
+ setActiveTab('response')}
+ >
+ Response
+
+ {showNetworkLogs && (
+ setActiveTab('networkLogs')}
+ >
+ Network Logs
+
+ )}
+
+
+ {/* Tab Content */}
+
+ {/* Request Tab */}
+ {activeTab === 'request' && (
+
+ )}
+
+ {/* Response Tab */}
+ {activeTab === 'response' && (
+
+ )}
+
+ {/* Network Logs Tab */}
+ {activeTab === 'networkLogs' && showNetworkLogs && (
+
+ )}
+
+
)}
+
+ );
+};
+
+export default TimelineItem;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
index d8a4770a5..40fccf969 100644
--- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
@@ -1,60 +1,123 @@
-import React from 'react';
-import forOwn from 'lodash/forOwn';
-import { safeStringifyJSON } from 'utils/common';
+import React, { useState } from 'react';
import StyledWrapper from './StyledWrapper';
+import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
+import { get } from 'lodash';
+import TimelineItem from './TimelineItem/index';
-const Timeline = ({ request, response }) => {
- const requestHeaders = [];
- const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
+const getEffectiveAuthSource = (collection, item) => {
+ const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+ if (authMode !== 'inherit') return null;
- request = request || {};
- response = response || {};
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ uid: collection.uid,
+ auth: collectionAuth
+ };
- forOwn(request.headers, (value, key) => {
- requestHeaders.push({
- name: key,
- value
- });
- });
+ // Get path from collection to item
+ let path = [];
+ let currentItem = findItemInCollection(collection, item?.uid);
+ while (currentItem) {
+ path.unshift(currentItem);
+ currentItem = findParentItemInCollection(collection, currentItem?.uid);
+ }
- let requestData = safeStringifyJSON(request.data);
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...path].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ uid: i.uid,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+};
+
+const Timeline = ({ collection, item, width }) => {
+ // Get the effective auth source if auth mode is inherit
+ const authSource = getEffectiveAuthSource(collection, item);
+
+ // Filter timeline entries based on new rules
+ const combinedTimeline = ([...(collection.timeline || [])]).filter(obj => {
+ // Always show entries for this item
+ if (obj.itemUid === item.uid) return true;
+
+ // For OAuth2 entries, also show if auth is inherited
+ if (obj.type === 'oauth2' && authSource) {
+ if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
+ if (authSource.type === 'collection' && !obj.folderUid) return true;
+ }
+
+ return false;
+ }).sort((a, b) => b.timestamp - a.timestamp);
return (
-
-
-
- {'>'} {request.method} {request.url}
-
- {requestHeaders.map((h) => {
+
+ {combinedTimeline.map((event, index) => {
+ if (event.type === 'request') {
+ const { data, timestamp } = event;
+ const { request, response } = data;
return (
-
- {'>'} {h.name}: {h.value}
-
+
+
+
);
- })}
-
- {requestData ? (
-
- {'>'} data {requestData}
-
- ) : null}
-
-
-
-
- {'<'} {response.status} {response.statusText}
-
-
- {responseHeaders.map((h) => {
+ } else if (event.type === 'oauth2') {
+ const { data, timestamp } = event;
+ const { debugInfo } = data;
return (
-
- {'<'} {h[0]}: {h[1]}
-
+
+
+
+ {debugInfo && debugInfo.length > 0 ? (
+ debugInfo.map((data, idx) => (
+
+
+
+ ))
+ ) : (
+
No debug information available.
+ )}
+
+
);
- })}
-
+ }
+
+ return null;
+ })}
);
};
-export default Timeline;
+export default Timeline;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js
index 02edc106e..71e55cdd5 100644
--- a/packages/bruno-app/src/components/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,15 +13,30 @@ import ResponseSize from './ResponseSize';
import Timeline from './Timeline';
import TestResults from './TestResults';
import TestResultsLabel from './TestResultsLabel';
+import ScriptError from './ScriptError';
+import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
+import SkippedRequest from './SkippedRequest';
+import ClearTimeline from './ClearTimeline/index';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
+ const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
+
+ const requestTimeline = ([...(collection.timeline || [])]).filter(obj => {
+ if (obj.itemUid === item.uid) return true;
+ });
+
+ useEffect(() => {
+ if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) {
+ setShowScriptErrorCard(true);
+ }
+ }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -33,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
+ const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -54,10 +70,15 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return
;
}
case 'timeline': {
- return
;
+ return
;
}
case 'tests': {
- return
;
+ return
;
}
default: {
@@ -66,6 +87,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
}
};
+ if (item.response && item.status === 'skipped') {
+ return (
+
+
+
+ );
+ }
+
if (isLoading && !item.response) {
return (
@@ -74,7 +103,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
);
}
- if (!item.response) {
+ if (!item.response && !requestTimeline?.length) {
return (
@@ -97,6 +126,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
+ const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
+
+ const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage;
+
return (
@@ -105,29 +138,62 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
selectTab('headers')}>
Headers
- {response.headers?.length > 0 && {response.headers.length} }
+ {responseHeadersCount > 0 && {responseHeadersCount} }
selectTab('timeline')}>
Timeline
selectTab('tests')}>
-
+
{!isLoading ? (
-
-
-
-
-
+ {hasScriptError && !showScriptErrorCard && (
+ setShowScriptErrorCard(true)}
+ />
+ )}
+ {focusedTab?.responsePaneTab === "timeline" ? (
+
+ ) : (item?.response && !item?.response?.error) ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
) : null}
{isLoading ? : null}
- {getTabPanel(focusedTab.responsePaneTab)}
+ {hasScriptError && showScriptErrorCard && (
+ setShowScriptErrorCard(false)}
+ />
+ )}
+ {!item?.response ? (
+ focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
+
+ ) : null
+ ) : (
+ <>{getTabPanel(focusedTab.responsePaneTab)}>
+ )}
);
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
index 0b49d66ca..aa91e576c 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
@@ -33,6 +33,10 @@ const StyledWrapper = styled.div`
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
+
+ .skipped-request {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
index 007d398c0..21f02406e 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
@@ -7,15 +7,16 @@ import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseTime from 'components/ResponsePane/ResponseTime';
import ResponseSize from 'components/ResponsePane/ResponseSize';
-import Timeline from 'components/ResponsePane/Timeline';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
+import SkippedRequest from 'components/ResponsePane/SkippedRequest';
+import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
- const { requestSent, responseReceived, testResults, assertionResults } = item;
+ const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item;
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
@@ -36,6 +37,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
data={responseReceived.data}
dataBuffer={responseReceived.dataBuffer}
headers={responseReceived.headers}
+ error={error}
key={item.filename}
/>
);
@@ -44,10 +46,15 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return
;
}
case 'timeline': {
- return
;
+ return
;
}
case 'tests': {
- return
;
+ return
;
}
default: {
@@ -62,6 +69,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
+ if (item.status === 'skipped') {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -76,7 +91,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
selectTab('tests')}>
-
+
diff --git a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
index 0178b90d7..b3fbaaebd 100644
--- a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
@@ -1,6 +1,19 @@
import styled from 'styled-components';
const Wrapper = styled.div`
+ .textbox {
+ border: 1px solid #ccc;
+ padding: 0.2rem 0.5rem;
+ box-shadow: none;
+ border-radius: 0px;
+ outline: none;
+ box-shadow: none;
+ transition: border-color ease-in-out 0.1s;
+ border-radius: 3px;
+ background-color: ${(props) => props.theme.modal.input.bg};
+ border: 1px solid ${(props) => props.theme.modal.input.border};
+ }
+
.item-path {
.link {
color: ${(props) => props.theme.textLink};
@@ -26,6 +39,10 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
}
+
+ .skipped-request {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx
index e415aeb3c..cbf099e5b 100644
--- a/packages/bruno-app/src/components/RunnerResults/index.jsx
+++ b/packages/bruno-app/src/components/RunnerResults/index.jsx
@@ -1,28 +1,47 @@
import React, { useState, useRef, useEffect } from 'react';
-import path from 'path';
+import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
-import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
-import slash from 'utils/common/slash';
+import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
+import { areItemsLoading } from 'utils/collections';
-const getRelativePath = (fullPath, pathname) => {
- // convert to unix style path
- fullPath = slash(fullPath);
- pathname = slash(pathname);
-
+const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
- const { dir, name } = path.parse(relativePath);
+ const { dir = '' } = path.parse(relativePath);
return path.join(dir, name);
};
+const getTestStatus = (results) => {
+ if (!results || !results.length) return 'pass';
+ const failed = results.filter((result) => result.status === 'fail');
+ return failed.length ? 'fail' : 'pass';
+};
+
+const allTestsPassed = (item) => {
+ return item.status !== 'error' &&
+ item.testStatus === 'pass' &&
+ item.assertionStatus === 'pass' &&
+ item.preRequestTestStatus === 'pass' &&
+ item.postResponseTestStatus === 'pass';
+};
+
+const anyTestFailed = (item) => {
+ return item.status === 'error' ||
+ item.testStatus === 'fail' ||
+ item.assertionStatus === 'fail' ||
+ item.preRequestTestStatus === 'fail' ||
+ item.postResponseTestStatus === 'fail';
+};
+
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
+ const [delay, setDelay] = useState(null);
// ref for the runner output body
const runnerBodyRef = useRef();
@@ -56,33 +75,24 @@ export default function RunnerResults({ collection }) {
type: info.type,
filename: info.filename,
pathname: info.pathname,
- relativePath: getRelativePath(collection.pathname, info.pathname)
+ displayName: getDisplayName(collection.pathname, info.pathname, info.name)
};
- if (newItem.status !== 'error') {
- if (newItem.testResults) {
- const failed = newItem.testResults.filter((result) => result.status === 'fail');
- newItem.testStatus = failed.length ? 'fail' : 'pass';
- } else {
- newItem.testStatus = 'pass';
- }
-
- if (newItem.assertionResults) {
- const failed = newItem.assertionResults.filter((result) => result.status === 'fail');
- newItem.assertionStatus = failed.length ? 'fail' : 'pass';
- } else {
- newItem.assertionStatus = 'pass';
- }
+ if (newItem.status !== 'error' && newItem.status !== 'skipped') {
+ newItem.testStatus = getTestStatus(newItem.testResults);
+ newItem.assertionStatus = getTestStatus(newItem.assertionResults);
+ newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
+ newItem.postResponseTestStatus = getTestStatus(newItem.postResponseTestResults);
}
return newItem;
})
.filter(Boolean);
const runCollection = () => {
- dispatch(runCollectionFolder(collection.uid, null, true));
+ dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
};
const runAgain = () => {
- dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive));
+ dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
};
const resetRunner = () => {
@@ -98,12 +108,13 @@ export default function RunnerResults({ collection }) {
};
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
- const passedRequests = items.filter((item) => {
- return item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
- });
- const failedRequests = items.filter((item) => {
- return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
+ const passedRequests = items.filter(allTestsPassed);
+ const failedRequests = items.filter(anyTestFailed);
+
+ const skippedRequests = items.filter((item) => {
+ return item.status === 'skipped';
});
+ let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
@@ -115,6 +126,20 @@ export default function RunnerResults({ collection }) {
You have {totalRequestsInCollection} requests in this collection.
+ {isCollectionLoading ?
Requests in this collection are still loading.
: null}
+
+ Delay (in ms)
+ setDelay(e.target.value)}
+ />
+
Run Collection
@@ -146,37 +171,92 @@ export default function RunnerResults({ collection }) {
ref={runnerBodyRef}
>
- Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}
+ Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
+ {skippedRequests.length}
+ {runnerInfo?.statusText ?
+
+ {runnerInfo?.statusText}
+
+ : null}
{items.map((item) => {
return (
- {item.status !== 'error' && item.testStatus === 'pass' ? (
+ {allTestsPassed(item) ?
- ) : (
+ : null}
+ {item.status === 'skipped' ?
+
+ :null}
+ {anyTestFailed(item) ?
- )}
+ :null}
- {item.relativePath}
+ {item.displayName}
- {item.status !== 'error' && item.status !== 'completed' ? (
+ {item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
- ) : (
+ ) : item.responseReceived?.status ? (
setSelectedItem(item)}>
- ({get(item.responseReceived, 'status')}
- {get(item.responseReceived, 'statusText')} )
+ {item.responseReceived?.status}
+ -
+ {item.responseReceived?.statusText}
+
+ ) : (
+ setSelectedItem(item)}>
+ (request failed)
)}
{item.status == 'error' ?
{item.error}
: null}
+ {item.preRequestTestResults
+ ? item.preRequestTestResults.map((result) => (
+
+ {result.status === 'pass' ? (
+
+
+ {result.description}
+
+ ) : (
+ <>
+
+
+ {result.description}
+
+ {result.error}
+ >
+ )}
+
+ ))
+ : null}
+ {item.postResponseTestResults
+ ? item.postResponseTestResults.map((result) => (
+
+ {result.status === 'pass' ? (
+
+
+ {result.description}
+
+ ) : (
+ <>
+
+
+ {result.description}
+
+ {result.error}
+ >
+ )}
+
+ ))
+ : null}
{item.testResults
? item.testResults.map((result) => (
@@ -238,16 +318,19 @@ export default function RunnerResults({ collection }) {
- {selectedItem.relativePath}
+ {selectedItem.displayName}
- {selectedItem.testStatus === 'pass' ? (
+ {allTestsPassed(selectedItem) ?
- ) : (
-
- )}
+ : null}
+ {anyTestFailed(selectedItem) ?
+
+ : null}
+ {selectedItem.status === 'skipped' ?
+
+ : null}
- {/*
{selectedItem.relativePath}
*/}
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/StyledWrapper.js
new file mode 100644
index 000000000..cad253fd9
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/StyledWrapper.js
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .safe-mode {
+ padding: 0.15rem 0.3rem;
+ color: ${(props) => props.theme.colors.text.green};
+ border: solid 1px ${(props) => props.theme.colors.text.green} !important;
+ }
+ .developer-mode {
+ padding: 0.15rem 0.3rem;
+ color: ${(props) => props.theme.colors.text.yellow};
+ border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/index.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/index.js
new file mode 100644
index 000000000..c4ab2dbf2
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxMode/index.js
@@ -0,0 +1,45 @@
+import { useDispatch } from 'react-redux';
+import { IconShieldLock } from '@tabler/icons';
+import { addTab } from 'providers/ReduxStore/slices/tabs';
+import { uuid } from 'utils/common/index';
+import JsSandboxModeModal from '../JsSandboxModeModal';
+import StyledWrapper from './StyledWrapper';
+
+const JsSandboxMode = ({ collection }) => {
+ const jsSandboxMode = collection?.securityConfig?.jsSandboxMode;
+ const dispatch = useDispatch();
+
+ const viewSecuritySettings = () => {
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid: collection.uid,
+ type: 'security-settings'
+ })
+ );
+ };
+
+ return (
+
+ {jsSandboxMode === 'safe' && (
+
+ Safe Mode
+
+ )}
+ {jsSandboxMode === 'developer' && (
+
+ Developer Mode
+
+ )}
+ {!jsSandboxMode ? : null}
+
+ );
+};
+
+export default JsSandboxMode;
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js
new file mode 100644
index 000000000..ecaab4ff1
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ max-width: 800px;
+
+ span.beta-tag {
+ display: flex;
+ align-items: center;
+ padding: 0.1rem 0.25rem;
+ font-size: 0.75rem;
+ border-radius: 0.25rem;
+ color: ${(props) => props.theme.colors.text.green};
+ border: solid 1px ${(props) => props.theme.colors.text.green} !important;
+ }
+
+ span.developer-mode-warning {
+ font-weight: 400;
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js
new file mode 100644
index 000000000..52a988ea7
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js
@@ -0,0 +1,98 @@
+import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+import toast from 'react-hot-toast';
+import { useState } from 'react';
+import Portal from 'components/Portal';
+import Modal from 'components/Modal';
+import StyledWrapper from './StyledWrapper';
+
+const JsSandboxModeModal = ({ collection }) => {
+ const dispatch = useDispatch();
+ const [jsSandboxMode, setJsSandboxMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
+
+ const handleChange = (e) => {
+ setJsSandboxMode(e.target.value);
+ };
+
+ const handleSave = () => {
+ dispatch(
+ saveCollectionSecurityConfig(collection?.uid, {
+ jsSandboxMode: jsSandboxMode
+ })
+ )
+ .then(() => {
+ toast.success('Sandbox mode updated successfully');
+ })
+ .catch((err) => console.log(err) && toast.error('Failed to update sandbox mode'));
+ };
+
+ return (
+
+
+
+
+ The collection might include JavaScript code in Variables, Scripts, Tests, and Assertions.
+
+
+
+ Please choose the security level for the JavaScript code execution.
+
+
+
+
+
+
+ Safe Mode
+
+ BETA
+
+
+ JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
+
+
+
+
+
+ Developer Mode
+ (use only if you trust the collections authors)
+
+
+
+ JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
+
+
+ * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
+
+
+
+
+
+ );
+};
+
+export default JsSandboxModeModal;
diff --git a/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js
new file mode 100644
index 000000000..ecaab4ff1
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ max-width: 800px;
+
+ span.beta-tag {
+ display: flex;
+ align-items: center;
+ padding: 0.1rem 0.25rem;
+ font-size: 0.75rem;
+ border-radius: 0.25rem;
+ color: ${(props) => props.theme.colors.text.green};
+ border: solid 1px ${(props) => props.theme.colors.text.green} !important;
+ }
+
+ span.developer-mode-warning {
+ font-weight: 400;
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SecuritySettings/index.js b/packages/bruno-app/src/components/SecuritySettings/index.js
new file mode 100644
index 000000000..7761760f6
--- /dev/null
+++ b/packages/bruno-app/src/components/SecuritySettings/index.js
@@ -0,0 +1,86 @@
+import { useState } from 'react';
+import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import StyledWrapper from './StyledWrapper';
+import { useDispatch } from 'react-redux';
+
+const SecuritySettings = ({ collection }) => {
+ const dispatch = useDispatch();
+ const [jsSandboxMode, setJsSandboxMode] = useState(collection?.securityConfig?.jsSandboxMode || 'safe');
+
+ const handleChange = (e) => {
+ setJsSandboxMode(e.target.value);
+ };
+
+ const handleSave = () => {
+ dispatch(
+ saveCollectionSecurityConfig(collection?.uid, {
+ jsSandboxMode: jsSandboxMode
+ })
+ )
+ .then(() => {
+ toast.success('Sandbox mode updated successfully');
+ })
+ .catch((err) => console.log(err) && toast.error('Failed to update sandbox mode'));
+ };
+
+ return (
+
+ JavaScript Sandbox
+
+
+ The collection might include JavaScript code in Variables, Scripts, Tests, and Assertions.
+
+
+
+
+
+
+
+ Safe Mode
+
+ BETA
+
+
+ JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
+
+
+
+
+
+ Developer Mode
+ (use only if you trust the collections authors)
+
+
+
+ JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
+
+
+
+ Save
+
+
+ * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
+
+
+
+ );
+};
+
+export default SecuritySettings;
diff --git a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js
new file mode 100644
index 000000000..5e1e3be3d
--- /dev/null
+++ b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js
@@ -0,0 +1,30 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .tabs {
+ .tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js
new file mode 100644
index 000000000..d0db00905
--- /dev/null
+++ b/packages/bruno-app/src/components/ShareCollection/index.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import Modal from 'components/Modal';
+import { IconDownload } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+import Bruno from 'components/Bruno';
+import exportBrunoCollection from 'utils/collections/export';
+import exportPostmanCollection from 'utils/exporters/postman-collection';
+import { cloneDeep } from 'lodash';
+import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
+import { useSelector } from 'react-redux';
+import { findCollectionByUid } from 'utils/collections/index';
+
+const ShareCollection = ({ onClose, collectionUid }) => {
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
+ const handleExportBrunoCollection = () => {
+ const collectionCopy = cloneDeep(collection);
+ exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
+ onClose();
+ };
+
+ const handleExportPostmanCollection = () => {
+ const collectionCopy = cloneDeep(collection);
+ exportPostmanCollection(collectionCopy);
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
Bruno Collection
+
Export in Bruno format
+
+
+
+
+
+
+
+
+
Postman Collection
+
Export in Postman format
+
+
+
+
+
+ );
+};
+
+export default ShareCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
index cd9857a15..a0a1e6c09 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
@@ -1,33 +1,44 @@
import React, { useRef, useEffect } from 'react';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
-import Tooltip from 'components/Tooltip';
import Modal from 'components/Modal';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay';
+import { useState } from 'react';
+import { IconArrowBackUp, IconEdit } from "@tabler/icons";
+import { findCollectionByUid } from 'utils/collections/index';
-const CloneCollection = ({ onClose, collection }) => {
+const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
+ const [isEditing, toggleEditing] = useState(false);
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
+ const { name } = collection;
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- collectionName: '',
- collectionFolderName: '',
+ collectionName: `${name} copy`,
+ collectionFolderName: `${sanitizeName(name)} copy`,
collectionLocation: ''
},
validationSchema: Yup.object({
collectionName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
+ .max(255, 'must be 255 characters or less')
+ .test('is-valid-collection-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
@@ -37,21 +48,21 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
- collection.pathname
+ collection?.pathname
)
)
.then(() => {
- toast.success('Collection created');
+ toast.success('Collection created!');
onClose();
})
- .catch(() => toast.error('An error occurred while creating the collection'));
+ .catch((e) => toast.error('An error occurred while creating the collection - ' + e));
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
- // When the user closes the diolog without selecting anything dirPath will be false
+ // When the user closes the dialog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
@@ -72,7 +83,7 @@ const CloneCollection = ({ onClose, collection }) => {
return (
-
+ e.preventDefault()}>
Name
@@ -85,9 +96,7 @@ const CloneCollection = ({ onClose, collection }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
- if (formik.values.collectionName === formik.values.collectionFolderName) {
- formik.setFieldValue('collectionFolderName', e.target.value);
- }
+ !isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -119,33 +128,70 @@ const CloneCollection = ({ onClose, collection }) => {
{formik.errors.collectionLocation}
) : null}
-
+
Browse
-
- Folder Name
-
-
-
- {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
- {formik.errors.collectionFolderName}
- ) : null}
+
+
+
+ Folder Name
+
+
+ The name of the folder used to store the collection.
+
+
+ You can choose a folder name different from your collection's name or one compatible with filesystem rules.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+ {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
+
{formik.errors.collectionFolderName}
+ ) : null}
+
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
index 55c2b86dd..3a4e2e9c8 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
@@ -1,4 +1,4 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useState, useRef, useEffect, forwardRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -6,25 +6,52 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
+import { IconArrowBackUp, IconEdit, IconCaretDown } from "@tabler/icons";
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay/index';
+import path from 'utils/common/path';
+import Portal from 'components/Portal';
+import Dropdown from 'components/Dropdown';
+import StyledWrapper from './StyledWrapper';
-const CloneCollectionItem = ({ collection, item, onClose }) => {
+const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const itemName = item?.name;
+ const itemType = item?.type;
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- name: item.name
+ name: `${itemName} copy`,
+ filename: `${sanitizeName(itemName)} copy`
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
.required('name is required')
+ .test('is-valid-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
- dispatch(cloneItem(values.name, item.uid, collection.uid))
+ dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
+ toast.success('Request cloned!');
onClose();
})
.catch((err) => {
@@ -39,38 +66,157 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
-
- {isFolder ? 'Folder' : 'Request'} Name
-
-
- {formik.touched.name && formik.errors.name ?
{formik.errors.name}
: null}
-
-
-
+
+
+
+
+
+
+ {isFolder ? 'Folder' : 'Request'} Name
+
+
{
+ formik.setFieldValue('name', e.target.value);
+ !isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
+ }}
+ value={formik.values.name || ''}
+ />
+ {formik.touched.name && formik.errors.name ?
{formik.errors.name}
: null}
+
+
+ {showFilesystemName && (
+
+
+
+ {isFolder ? 'Folder' : 'File'} Name (on filesystem)
+ { isFolder ? (
+
+
+ You can choose to save the folder as a different name on your file system versus what is displayed in the app.
+
+
+ ) : (
+
+
+ Bruno saves each request as a file in your collection's folder.
+
+
+ You can choose a file name different from your request's name or one compatible with filesystem rules.
+
+
+ )}
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+
+ {itemType !== 'folder' && .bru }
+
+ ) : (
+
+ )}
+ {formik.touched.filename && formik.errors.filename ? (
+
{formik.errors.filename}
+ ) : null}
+
+ )}
+
+
+
+
} placement="bottom-start">
+
{
+ dropdownTippyRef.current.hide();
+ toggleShowFilesystemName(!showFilesystemName);
+ }}
+ >
+ {showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Clone
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js
new file mode 100644
index 000000000..62f53069e
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .drag-preview {
+ background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js
new file mode 100644
index 000000000..1ad4065a8
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js
@@ -0,0 +1,49 @@
+import { useDragLayer } from 'react-dnd';
+import {
+ IconFile,
+ IconFolder,
+} from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+function getItemStyles({ x, y }) {
+ if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
+ const transform = `translate(${x}px, ${y}px)`;
+
+ return {
+ position: 'fixed',
+ pointerEvents: 'none',
+ top: 0,
+ transform,
+ WebkitTransform: transform,
+ zIndex: 100,
+ };
+}
+
+export const CollectionItemDragPreview = () => {
+ const {
+ item,
+ isDragging,
+ clientOffset
+ } = useDragLayer((monitor) => ({
+ item: monitor.getItem(),
+ isDragging: monitor.isDragging(),
+ clientOffset: monitor.getClientOffset(),
+ }));
+ if (!isDragging) return null;
+ const { x, y } = clientOffset || {};
+ const shouldShowFolderIcon = !item.type || item.type === 'folder';
+ return (
+
+
+
+ {shouldShowFolderIcon ? (
+
+ ) : (
+
+ )}
+ {item.name}
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js
new file mode 100644
index 000000000..66bfe719b
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .partial {
+ color: ${(props) => 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/CollectionItemInfo/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemInfo/index.js
new file mode 100644
index 000000000..ca46d0d79
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemInfo/index.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import Modal from 'components/Modal';
+import Help from 'components/Help';
+
+const CollectionItemInfo = ({ item, onClose }) => {
+ const { name, filename, type } = item;
+
+ return (
+
+
+
+
+
+
+ {type=='folder' ? 'Folder Name' : 'Request Name'}
+
+
+ : {name}
+
+
+
+
+ {type == 'folder' ? 'Folder Name' : 'File Name'}
+ (on filesystem)
+ {type == 'folder' ? (
+
+
+ The name of the folder on your filesystem.
+
+
+ ) : (
+
+
+ Bruno saves each request as a file in your collection's folder.
+
+
+ )}
+
+
+ :
+ {filename}
+
+
+
+
+
+
+ );
+};
+
+export default CollectionItemInfo;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
index 59f19dcb2..3f397c78c 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
@@ -7,15 +7,20 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
-const DeleteCollectionItem = ({ onClose, item, collection }) => {
+const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
- dispatch(deleteItem(item.uid, collection.uid)).then(() => {
+ dispatch(deleteItem(item.uid, collectionUid)).then(() => {
+
if (isFolder) {
+ // close all tabs that belong to the folder
+ // including the folder itself and its children
+ const tabUids = [...recursivelyGetAllItemUids(item.items), item.uid]
+
dispatch(
closeTabs({
- tabUids: recursivelyGetAllItemUids(item.items)
+ tabUids: tabUids
})
);
} else {
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
index 418658f03..ff06f4f31 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
@@ -2,6 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
+ height: 100%;
.copy-to-clipboard {
position: absolute;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
index f0fa506b2..ea3ed43a7 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
@@ -8,24 +8,42 @@ import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
-import { findCollectionByItemUid } from '../../../../../../../utils/collections/index';
+import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
+import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
+import { cloneDeep } from 'lodash';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
+ const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const { target, client, language: lang } = language;
const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
- const collection = findCollectionByItemUid(
+ let _collection = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
- const headers = [...(collection?.root?.request?.headers || []), ...(requestHeaders || [])];
+ let collection = cloneDeep(_collection);
+
+ // add selected global env variables to the collection object
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ collection.globalEnvironmentVariables = globalEnvironmentVariables;
+
+ const collectionRootAuth = collection?.root?.request?.auth;
+ const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
+
+ const headers = [
+ ...getAuthHeaders(collectionRootAuth, requestAuth),
+ ...(collection?.root?.request?.headers || []),
+ ...(requestHeaders || [])
+ ];
let snippet = '';
-
try {
- snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers })).convert(target, client);
+ snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert(
+ target,
+ client
+ );
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
@@ -43,8 +61,11 @@ const CodeView = ({ language, item }) => {
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
index 635c545e9..3d8ea1229 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
@@ -8,8 +8,9 @@ const StyledWrapper = styled.div`
.generate-code-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
- min-height: 400px;
+ max-height: 80vh;
height: 100%;
+ overflow-y: auto;
}
.generate-code-item {
@@ -34,6 +35,28 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
+
+ .flexible-container {
+ width: 100%;
+ }
+
+ @media (max-width: 600px) {
+ .flexible-container {
+ width: 500px;
+ }
+ }
+
+ @media (min-width: 601px) and (max-width: 1200px) {
+ .flexible-container {
+ width: 800px;
+ }
+ }
+
+ @media (min-width: 1201px) {
+ .flexible-container {
+ width: 900px;
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
index ed1bc3f64..f31caf9ab 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
@@ -2,82 +2,71 @@ import Modal from 'components/Modal/index';
import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
-import { isValidUrl } from 'utils/url/index';
-import get from 'lodash/get';
-import { findEnvironmentInCollection } from 'utils/collections';
+import { isValidUrl } from 'utils/url';
+import { get } from 'lodash';
+import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
+import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
+import { getLanguages } from 'utils/codegenerator/targets';
+import { useSelector } from 'react-redux';
+import { getGlobalEnvironmentVariables } from 'utils/collections/index';
-// Todo: Fix this
-// import { interpolate } from '@usebruno/common';
-import brunoCommon from '@usebruno/common';
-const { interpolate } = brunoCommon;
-
-const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) => {
- if (!url || !url.length || typeof url !== 'string') {
- return;
+const getTreePathFromCollectionToItem = (collection, _itemUid) => {
+ let path = [];
+ let item = findItemInCollection(collection, _itemUid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
}
-
- return interpolate(url, {
- ...envVars,
- ...collectionVariables,
- process: {
- env: {
- ...processEnvVars
- }
- }
- });
+ return path;
};
-const languages = [
- {
- name: 'HTTP',
- target: 'http',
- client: 'http1.1'
- },
- {
- name: 'JavaScript-Fetch',
- target: 'javascript',
- client: 'fetch'
- },
- {
- name: 'Javascript-jQuery',
- target: 'javascript',
- client: 'jquery'
- },
- {
- name: 'Javascript-axios',
- target: 'javascript',
- client: 'axios'
- },
- {
- name: 'Python-Python3',
- target: 'python',
- client: 'python3'
- },
- {
- name: 'Python-Requests',
- target: 'python',
- client: 'requests'
- },
- {
- name: 'PHP',
- target: 'php',
- client: 'curl'
- },
- {
- name: 'Shell-curl',
- target: 'shell',
- client: 'curl'
- },
- {
- name: 'Shell-httpie',
- target: 'shell',
- client: 'httpie'
+// Function to resolve inherited auth
+const resolveInheritedAuth = (item, collection) => {
+ const request = item.draft?.request || item.request;
+ const authMode = request?.auth?.mode;
+
+ // If auth is not inherit or no auth defined, return the request as is
+ if (!authMode || authMode !== 'inherit') {
+ return {
+ ...request
+ };
}
-];
-const GenerateCodeItem = ({ collection, item, onClose }) => {
- const url = get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
- const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
+ // Get the tree path from collection to item
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
+
+ // Default to collection auth
+ const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
+ let effectiveAuth = collectionAuth;
+ let source = 'collection';
+
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...requestTreePath].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveAuth = folderAuth;
+ source = 'folder';
+ break;
+ }
+ }
+ }
+
+ return {
+ ...request,
+ auth: effectiveAuth
+ };
+};
+
+const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
+ const languages = getLanguages();
+
+ const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
+
+ const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+
+ const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid);
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
@@ -87,17 +76,32 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
}, {});
}
+ const requestUrl =
+ get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
+
+ // interpolate the url
const interpolatedUrl = interpolateUrl({
- url,
+ url: requestUrl,
+ globalEnvironmentVariables,
envVars,
- collectionVariables: collection.collectionVariables,
+ runtimeVariables: collection.runtimeVariables,
processEnvVars: collection.processEnvVariables
});
+
+ // interpolate the path params
+ const finalUrl = interpolateUrlPathParams(
+ interpolatedUrl,
+ get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
+ );
+
+ // Resolve auth inheritance
+ const resolvedRequest = resolveInheritedAuth(item, collection);
+
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
-
+
{languages &&
@@ -108,7 +112,26 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
+ role="button"
+ tabIndex={0}
onClick={() => setSelectedLanguage(language)}
+ onKeyDown={(e) => {
+ if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) {
+ e.preventDefault();
+ const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name);
+ const nextIndex = e.shiftKey
+ ? (currentIndex - 1 + languages.length) % languages.length
+ : (currentIndex + 1) % languages.length;
+ setSelectedLanguage(languages[nextIndex]);
+
+ // Explicitly focus on the new active element
+ const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`);
+ nextElement?.focus();
+ }
+
+ }}
+ data-language={language.name}
+ aria-pressed={language.name === selectedLanguage.name}
>
{language.name}
@@ -116,27 +139,22 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
- {isValidUrl(interpolatedUrl) ? (
+ {isValidUrl(finalUrl) ? (
) : (
-
Invalid URL: {interpolatedUrl}
+
Invalid URL: {finalUrl}
Please check the URL and try again
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
index 74b25de47..6b0cb086a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
@@ -1,34 +1,82 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import path from 'utils/common/path';
+import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import toast from 'react-hot-toast';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay';
+import Portal from 'components/Portal';
+import Dropdown from 'components/Dropdown';
+import StyledWrapper from './StyledWrapper';
-const RenameCollectionItem = ({ collection, item, onClose }) => {
+const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const itemName = item?.name;
+ const itemType = item?.type;
+ const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- name: item.name
+ name: itemName,
+ filename: sanitizeName(itemFilename)
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
.required('name is required')
+ .test('is-valid-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: async (values) => {
// if there is unsaved changes in the request,
// save them before renaming the request
- if (!isFolder && item.draft) {
- await dispatch(saveRequest(item.uid, collection.uid, true));
+ if ((item.name === values.name) && (itemFilename === values.filename)) {
+ return;
+ }
+ if (!isFolder && item.draft) {
+ await dispatch(saveRequest(item.uid, collectionUid, true));
+ }
+ const { name: newName, filename: newFilename } = values;
+ try {
+ let renameConfig = {
+ itemUid: item.uid,
+ collectionUid,
+ };
+ renameConfig['newName'] = newName;
+ if (itemFilename !== newFilename) {
+ renameConfig['newFilename'] = newFilename;
+ }
+ await dispatch(renameItem(renameConfig));
+ if (isFolder) {
+ dispatch(closeTabs({ tabUids: [item.uid] }));
+ }
+ onClose();
+ } catch (error) {
+ toast.error(error.message || 'An error occurred while renaming');
}
- dispatch(renameItem(values.name, item.uid, collection.uid));
- onClose();
}
});
@@ -38,38 +86,155 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
-
- {isFolder ? 'Folder' : 'Request'} Name
-
-
- {formik.touched.name && formik.errors.name ?
{formik.errors.name}
: null}
-
-
-
+
+
+
+
+
+
+ {isFolder ? 'Folder' : 'Request'} Name
+
+
{
+ formik.setFieldValue('name', e.target.value);
+ !isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
+ }}
+ value={formik.values.name || ''}
+ />
+ {formik.touched.name && formik.errors.name ?
{formik.errors.name}
: null}
+
+
+ {showFilesystemName && (
+
+
+
+ {isFolder ? 'Folder' : 'File'} Name (on filesystem)
+ { isFolder ? (
+
+
+ You can choose to save the folder as a different name on your file system versus what is displayed in the app.
+
+
+ ) : (
+
+
+ Bruno saves each request as a file in your collection's folder.
+
+
+ You can choose a file name different from your request's name or one compatible with filesystem rules.
+
+
+ )}
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+
+ {itemType !== 'folder' && .bru }
+
+ ) : (
+
+ )}
+ {formik.touched.filename && formik.errors.filename ? (
+
{formik.errors.filename}
+ ) : null}
+
+ )}
+
+
+
} placement="bottom-start">
+
{
+ dropdownTippyRef.current.hide();
+ toggleShowFilesystemName(!showFilesystemName);
+ }}
+ >
+ {showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Rename
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
index 87315dfea..e41309871 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
@@ -23,7 +23,9 @@ const RequestMethod = ({ item }) => {
return (
- {item.request.method}
+
+ {item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
+
);
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 8c3da90a7..f56d408b0 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
@@ -2,15 +2,19 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
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 RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
+ const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
+ const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
+
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -19,47 +23,84 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
type: 'collection-runner'
})
);
- dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
+ if (!isCollectionRunInProgress) {
+ dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
+ }
onClose();
};
- const runLength = item ? get(item, 'items.length', 0) : get(collection, 'items.length', 0);
- const items = flattenItems(item ? item.items : collection.items);
- const requestItems = items.filter((item) => item.type !== 'folder');
- const recursiveRunLength = requestItems.length;
+ const handleViewRunner = (e) => {
+ e.preventDefault();
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid: collection.uid,
+ type: 'collection-runner'
+ })
+ );
+ onClose();
+ }
+
+ const getRequestsCount = (items) => {
+ const requestTypes = ['http-request', 'graphql-request']
+ return items.filter(req => requestTypes.includes(req.type)).length;
+ }
+
+ const runLength = item ? getRequestsCount(item.items) : get(collection, 'items.length', 0);
+ const flattenedItems = flattenItems(item ? item.items : collection.items);
+ const recursiveRunLength = getRequestsCount(flattenedItems);
+
+ const isFolderLoading = areItemsLoading(item);
return (
-
- Run
- ({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.
-
-
-
-
- Cancel
-
-
-
- onSubmit(true)}>
- Recursive Run
-
-
-
- onSubmit(false)}>
- Run
-
-
-
+ {!runLength && !recursiveRunLength ? (
+ No request found in this folder.
+ ) : (
+
+
+ Run
+ ({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.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null}
+ {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null}
+
+
+
+ Cancel
+
+
+ {
+ isCollectionRunInProgress ?
+
+
+ View Run
+
+
+ :
+ <>
+
+ onSubmit(true)}>
+ Recursive Run
+
+
+
+ onSubmit(false)}>
+ Run
+
+
+ >
+ }
+
+
+ )}
);
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
index 14d7432fa..4e3525df9 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
+ position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
+ position: relative;
+
+ /* Common styles for drop indicators */
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${(props) => props.theme.dragAndDrop.border};
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &::before {
+ top: 0;
+ }
+
+ &::after {
+ bottom: 0;
+ }
+
+ /* Drop target styles */
+ &.drop-target {
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+
+ &::before,
+ &::after {
+ opacity: 0;
+ }
+ }
+
+ &.drop-target-above {
+ &::before {
+ opacity: 1;
+ height: 2px;
+ }
+ }
+
+ &.drop-target-below {
+ &::after {
+ opacity: 1;
+ height: 2px;
+ }
+ }
+
+ /* Inside drop target style */
+ &.drop-target {
+ &::before {
+ top: 0;
+ bottom: 0;
+ height: 100%;
+ opacity: 1;
+ background: ${(props) => props.theme.dragAndDrop.hoverBg};
+ border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ // border-radius: 4px;
+ }
+ }
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
+ &.item-target {
+ background: #ccc3;
+ }
+
+ &.item-seperator {
+ .seperator {
+ bottom: 0px;
+ position: absolute;
+ height: 3px;
+ width: 100%;
+ background: #ccc3;
+ }
+ }
+
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};
@@ -65,7 +139,7 @@ const Wrapper = styled.div`
div.dropdown-item.delete-item {
color: ${(props) => props.theme.colors.danger};
&:hover {
- background-color: ${(props) => props.theme.colors.bg.danger};
+ background-color: ${(props) => props.theme.colors.bg.danger} !important;
color: white;
}
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index bf84b8289..0f59f4a69 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -1,36 +1,49 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
+import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
-import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
-import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
+import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
-import RequestMethod from './RequestMethod';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
-import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
+import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
+import CollectionItemInfo from './CollectionItemInfo/index';
+import CollectionItemIcon from './CollectionItemIcon';
+import { scrollToTheActiveTab } from 'utils/tabs';
+import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
+import { isEqual } from 'lodash';
+import { calculateDraggedItemNewPathname } from 'utils/collections/index';
-const CollectionItem = ({ item, collection, searchText }) => {
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
+ const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
+ const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
+
+ const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
+ const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
+
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
+ // We use a single ref for drag and drop.
+ const ref = useRef(null);
+
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
@@ -38,38 +51,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
- const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
+ const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
+ const hasSearchText = searchText && searchText?.trim()?.length;
+ const itemIsCollapsed = hasSearchText ? false : item.collapsed;
+ const isFolder = isItemAFolder(item);
- const [{ isDragging }, drag] = useDrag({
- type: `COLLECTION_ITEM_${collection.uid}`,
- item: item,
+ const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: `collection-item-${collectionUid}`,
+ item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
- })
- });
-
- const [{ isOver }, drop] = useDrop({
- accept: `COLLECTION_ITEM_${collection.uid}`,
- drop: (draggedItem) => {
- if (draggedItem.uid !== item.uid) {
- dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
- }
- },
- canDrop: (draggedItem) => {
- return draggedItem.uid !== item.uid;
- },
- collect: (monitor) => ({
- isOver: monitor.isOver()
- })
+ }),
+ options: {
+ dropEffect: "move"
+ }
});
useEffect(() => {
- if (searchText && searchText.length) {
- setItemisCollapsed(false);
+ dragPreview(getEmptyImage(), { captureDraggingState: true });
+ }, []);
+
+ const determineDropType = (monitor) => {
+ const hoverBoundingRect = ref.current?.getBoundingClientRect();
+ const clientOffset = monitor.getClientOffset();
+ if (!hoverBoundingRect || !clientOffset) return null;
+
+ const clientY = clientOffset.y - hoverBoundingRect.top;
+ const folderUpperThreshold = hoverBoundingRect.height * 0.35;
+ const fileUpperThreshold = hoverBoundingRect.height * 0.5;
+
+ if (isItemAFolder(item)) {
+ return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
- setItemisCollapsed(item.collapsed);
+ return clientY < fileUpperThreshold ? 'adjacent' : null;
}
- }, [searchText, item]);
+ };
+
+ const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
+ const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
+ const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return false;
+
+ const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
+ if (!newPathname) return false;
+
+ if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
+
+ return true;
+ };
+
+ const [{ isOver, canDrop }, drop] = useDrop({
+ accept: `collection-item-${collectionUid}`,
+ hover: (draggedItem, monitor) => {
+ const { uid: targetItemUid } = item;
+ const { uid: draggedItemUid } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return;
+
+ const dropType = determineDropType(monitor);
+
+ const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
+
+ setDropType(_canItemBeDropped ? dropType : null);
+ },
+ drop: async (draggedItem, monitor) => {
+ const { uid: targetItemUid } = item;
+ const { uid: draggedItemUid } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return;
+
+ const dropType = determineDropType(monitor);
+ if (!dropType) return;
+
+ await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
+ setDropType(null);
+ },
+ canDrop: (draggedItem) => draggedItem.uid !== item.uid,
+ collect: (monitor) => ({
+ isOver: monitor.isOver()
+ }),
+ });
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
@@ -84,20 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
- const itemRowClassName = classnames('flex collection-item-name items-center', {
- 'item-focused-in-tab': item.uid == activeTabUid,
- 'item-hovered': isOver
+ const itemRowClassName = classnames('flex collection-item-name relative items-center', {
+ 'item-focused-in-tab': isTabForItemActive,
+ 'item-hovered': isOver && canDrop,
+ 'drop-target': isOver && dropType === 'inside',
+ 'drop-target-above': isOver && dropType === 'adjacent'
});
- const scrollToTheActiveTab = () => {
- const activeTab = document.querySelector('.request-tab.active');
- if (activeTab) {
- activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- };
-
const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid)).catch((err) =>
+ dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) =>
toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -105,12 +164,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
+ if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
-
- if (isItemARequest(item)) {
+ const isRequest = isItemARequest(item);
+ if (isRequest) {
dispatch(hideHomePage());
- if (itemIsOpenedInTabs(item, tabs)) {
+ if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -121,16 +181,35 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
- collectionUid: collection.uid,
- requestPaneTab: getDefaultRequestPaneTab(item)
+ collectionUid: collectionUid,
+ requestPaneTab: getDefaultRequestPaneTab(item),
+ type: 'request',
+ })
+ );
+ } else {
+ dispatch(
+ addTab({
+ uid: item.uid,
+ collectionUid: collectionUid,
+ type: 'folder-settings',
+ })
+ );
+ dispatch(
+ collectionFolderClicked({
+ itemUid: item.uid,
+ collectionUid: collectionUid
})
);
- return;
}
+ };
+
+ const handleFolderCollapse = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
dispatch(
collectionFolderClicked({
itemUid: item.uid,
- collectionUid: collection.uid
+ collectionUid: collectionUid
})
);
};
@@ -146,13 +225,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
- const handleDoubleClick = (event) => {
- setRenameItemModalOpen(true);
- };
-
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -170,80 +244,108 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
- // we need to sort request items by seq property
- const sortRequestItems = (items = []) => {
+ const handleDoubleClick = (event) => {
+ dispatch(makeTabPermanent({ uid: item.uid }));
+ };
+
+ // Sort items by their "seq" property.
+ const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
- // we need to sort folder items by name alphabetically
- const sortFolderItems = (items = []) => {
- return items.sort((a, b) => a.name.localeCompare(b.name));
+ const handleShowInFolder = () => {
+ dispatch(showInFolder(item.pathname)).catch((error) => {
+ console.error('Error opening the folder', error);
+ toast.error('Error opening the folder');
+ });
};
+
+ const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
+ const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
+
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
- if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
+ if (
+ (item?.request?.url !== '') ||
+ (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
+ ) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
- const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
- const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
+
+ const viewFolderSettings = () => {
+ if (isItemAFolder(item)) {
+ if (isTabForItemPresent) {
+ dispatch(focusTab({ uid: item.uid }));
+ return;
+ }
+ dispatch(
+ addTab({
+ uid: item.uid,
+ collectionUid,
+ type: 'folder-settings'
+ })
+ );
+ }
+ };
return (
{renameItemModalOpen && (
- setRenameItemModalOpen(false)} />
+ setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
- setCloneItemModalOpen(false)} />
+ setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
- setDeleteItemModalOpen(false)} />
+ setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
- setNewRequestModalOpen(false)} />
+ setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
- setNewFolderModalOpen(false)} />
+ setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
- setRunCollectionModalOpen(false)} />
+ setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
- setGenerateCodeItemModalOpen(false)} />
+ setGenerateCodeItemModalOpen(false)} />
)}
- drag(drop(node))}>
+ {itemInfoModalOpen && (
+
setItemInfoModalOpen(false)} />
+ )}
+ {
+ ref.current = node;
+ drag(drop(node));
+ }}
+ >
{indents && indents.length
- ? indents.map((i) => {
- return (
-
- {/* Indent */}
-
- );
- })
+ ? indents.map((i) => (
+
+ {/* Indent */}
+
+ ))
: null}
{isFolder ? (
@@ -252,12 +354,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
strokeWidth={2}
className={iconClassName}
style={{ color: 'rgb(160 160 160)' }}
+ onClick={handleFolderCollapse}
/>
) : null}
-
-
-
+
+
{item.name}
@@ -326,7 +428,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
Run
)}
- {!isFolder && item.type === 'http-request' && (
+ {!isFolder && (item.type === 'http-request' || item.type === 'graphql-request') && (
{
@@ -336,6 +438,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
Generate Code
)}
+
{
+ dropdownTippyRef.current.hide();
+ handleShowInFolder();
+ }}
+ >
+ Show in Folder
+
{
@@ -345,21 +456,40 @@ const CollectionItem = ({ item, collection, searchText }) => {
>
Delete
+ {isFolder && (
+
{
+ dropdownTippyRef.current.hide();
+ viewFolderSettings();
+ }}
+ >
+ Settings
+
+ )}
+
{
+ dropdownTippyRef.current.hide();
+ setItemInfoModalOpen(true);
+ }}
+ >
+ Info
+
-
{!itemIsCollapsed ? (
{folderItems && folderItems.length
? folderItems.map((i) => {
- return ;
+ return ;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
- return ;
+ return ;
})
: null}
@@ -368,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
-export default CollectionItem;
+export default React.memo(CollectionItem);
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
index 92e252410..cdc400b65 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
@@ -1,7 +1,6 @@
import React from 'react';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
-import { toastError } from 'utils/common/error';
import cloneDeep from 'lodash/cloneDeep';
import Modal from 'components/Modal';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
index 9cba09179..17b6dc007 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
+import { findCollectionByUid } from 'utils/collections/index';
-const RemoveCollection = ({ onClose, collection }) => {
+const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
index 07a927415..0d3a4c34a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
+import { findCollectionByUid } from 'utils/collections/index';
-const RenameCollection = ({ collection, onClose }) => {
+const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@@ -17,13 +19,17 @@ const RenameCollection = ({ collection, onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
- dispatch(renameCollection(values.name, collection.uid));
- toast.success('Collection renamed!');
- onClose();
+ dispatch(renameCollection(values.name, collection.uid))
+ .then(() => {
+ toast.success('Collection renamed!');
+ onClose();
+ })
+ .catch((err) => {
+ toast.error(err ? err.message : 'An error occurred while renaming the collection');
+ });
}
});
@@ -37,7 +43,7 @@ const RenameCollection = ({ collection, onClose }) => {
return (
-
+ e.preventDefault()}>
Name
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
index b8e0d21fd..b47881fad 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
@@ -12,6 +12,18 @@ const Wrapper = styled.div`
transform: rotateZ(90deg);
}
+ &.item-hovered {
+ border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ border-bottom: 2px solid transparent;
+ .collection-actions {
+ .dropdown {
+ div[aria-expanded='false'] {
+ visibility: visible;
+ }
+ }
+ }
+ }
+
.collection-actions {
.dropdown {
div[aria-expanded='true'] {
@@ -51,6 +63,36 @@ const Wrapper = styled.div`
color: white;
}
}
+
+ &.drop-target {
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+
+ &.drop-target-above {
+ border: none;
+ border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ margin-top: -2px;
+ background: transparent;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+
+ &.drop-target-below {
+ border: none;
+ border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ margin-bottom: -2px;
+ background: transparent;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+ }
+
+ .collection-name.drop-target {
+ border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ border-radius: 4px;
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+ margin: -2px;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {
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 1c758f271..995be522c 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -1,37 +1,41 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
+import { getEmptyImage } from 'react-dnd-html5-backend';
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 { useDrop, useDrag } from 'react-dnd';
+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, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
-import { addTab } from 'providers/ReduxStore/slices/tabs';
+import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
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';
+import { scrollToTheActiveTab } from 'utils/tabs';
+import ShareCollection from 'components/ShareCollection/index';
+import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
- const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
+ const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
- const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const dispatch = useDispatch();
-
+ const isLoading = areItemsLoading(collection);
+ const collectionRef = useRef(null);
+
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -52,22 +56,55 @@ const Collection = ({ collection, searchText }) => {
);
};
- useEffect(() => {
- if (searchText && searchText.length) {
- setCollectionIsCollapsed(false);
- } else {
- setCollectionIsCollapsed(collection.collapsed);
+ const ensureCollectionIsMounted = () => {
+ if (collection.mountStatus === 'unmounted') {
+ dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig
+ }));
}
- }, [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));
+ if (event.detail != 1) return;
+ // Check if the click came from the chevron icon
+ const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
+ setTimeout(scrollToTheActiveTab, 50);
+
+ ensureCollectionIsMounted();
+
+ dispatch(collapseCollection(collection.uid));
+
+ if(!isChevronClick) {
+ dispatch(
+ addTab({
+ uid: collection.uid,
+ collectionUid: collection.uid,
+ type: 'collection-settings',
+ })
+ );
+ }
};
+ const handleDoubleClick = (event) => {
+ dispatch(makeTabPermanent({ uid: collection.uid }))
+ };
+
+ const handleCollectionCollapse = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ ensureCollectionIsMounted();
+ dispatch(collapseCollection(collection.uid));
+ }
+
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@@ -82,77 +119,108 @@ const Collection = ({ collection, searchText }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
- uid: uuid(),
+ uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
+ const isCollectionItem = (itemType) => {
+ return itemType.startsWith('collection-item');
+ };
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: "collection",
+ item: collection,
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ options: {
+ dropEffect: "move"
+ }
+ });
+
const [{ isOver }, drop] = useDrop({
- accept: `COLLECTION_ITEM_${collection.uid}`,
- drop: (draggedItem) => {
- dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
+ accept: ["collection", `collection-item-${collection.uid}`],
+ drop: (draggedItem, monitor) => {
+ const itemType = monitor.getItemType();
+ if (isCollectionItem(itemType)) {
+ dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
+ } else {
+ dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
+ }
},
canDrop: (draggedItem) => {
- // todo need to make sure that draggedItem belongs to the collection
- return true;
+ return draggedItem.uid !== collection.uid;
},
collect: (monitor) => ({
- isOver: monitor.isOver()
- })
+ isOver: monitor.isOver(),
+ }),
});
+ useEffect(() => {
+ dragPreview(getEmptyImage(), { captureDraggingState: true });
+ }, []);
+
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
+ const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
+ 'item-hovered': isOver
+ });
+
// we need to sort request items by seq property
- const sortRequestItems = (items = []) => {
+ const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
- // we need to sort folder items by name alphabetically
- const sortFolderItems = (items = []) => {
- return items.sort((a, b) => a.name.localeCompare(b.name));
- };
-
- const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
- const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
+ const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
+ const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
- {showNewRequestModal && setShowNewRequestModal(false)} />}
- {showNewFolderModal && setShowNewFolderModal(false)} />}
+ {showNewRequestModal && setShowNewRequestModal(false)} />}
+ {showNewFolderModal && setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
- setShowRenameCollectionModal(false)} />
+ setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
- setShowRemoveCollectionModal(false)} />
+ setShowRemoveCollectionModal(false)} />
)}
- {showExportCollectionModal && (
- setShowExportCollectionModal(false)} />
+ {showShareCollectionModal && (
+ setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
- setShowCloneCollectionModalOpen(false)} />
+ setShowCloneCollectionModalOpen(false)} />
)}
-
+
+
{
+ collectionRef.current = node;
+ drag(drop(node));
+ }}
+ >
-
} placement="bottom-start">
@@ -205,10 +273,10 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
- setShowExportCollectionModal(true);
+ setShowShareCollectionModal(true);
}}
>
- Export
+ Share
{
-
{!collectionIsCollapsed ? (
- {folderItems && folderItems.length
- ? folderItems.map((i) => {
- return ;
- })
- : null}
- {requestItems && requestItems.length
- ? requestItems.map((i) => {
- return ;
- })
- : null}
+ {folderItems?.map?.((i) => {
+ return ;
+ })}
+ {requestItems?.map?.((i) => {
+ return ;
+ })}
) : null}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index e5a657ef9..b7c8317f4 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -8,12 +8,10 @@ import {
IconSortDescendingLetters,
IconX
} from '@tabler/icons';
-import Collection from '../Collections/Collection';
+import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
@@ -91,13 +89,13 @@ const Collections = () => {
setSearchText(e.target.value.toLowerCase())}
/>
@@ -115,13 +113,11 @@ const Collections = () => {
)}
-
+
{collections && collections.length
? collections.map((c) => {
return (
-
-
-
+
);
})
: null}
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 168c922cd..7b331c42c 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -5,12 +5,19 @@ import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
-import Tooltip from 'components/Tooltip';
import Modal from 'components/Modal';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import PathDisplay from 'components/PathDisplay/index';
+import { useState } from 'react';
+import { IconArrowBackUp, IconEdit } from '@tabler/icons';
+import Help from 'components/Help';
+import { multiLineMsg } from "utils/common";
+import { formatIpcError } from "utils/common/error";
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
+ const [isEditing, toggleEditing] = useState(false);
const formik = useFormik({
enableReinitialize: true,
@@ -22,29 +29,32 @@ const CreateCollection = ({ onClose }) => {
validationSchema: Yup.object({
collectionName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
+ .max(255, 'must be 255 characters or less')
+ .test('is-valid-collection-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
onSubmit: (values) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
.then(() => {
- toast.success('Collection created');
+ toast.success('Collection created!');
onClose();
})
- .catch(() => toast.error('An error occurred while creating the collection'));
+ .catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
- // When the user closes the diolog without selecting anything dirPath will be false
+ // When the user closes the dialog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
@@ -65,7 +75,7 @@ const CreateCollection = ({ onClose }) => {
return (
-
+ e.preventDefault()}>
Name
@@ -78,9 +88,7 @@ const CreateCollection = ({ onClose }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
- if (formik.values.collectionName === formik.values.collectionFolderName) {
- formik.setFieldValue('collectionFolderName', e.target.value);
- }
+ !isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -92,14 +100,21 @@ const CreateCollection = ({ onClose }) => {
{formik.errors.collectionName}
) : null}
-
+
Location
+
+
+ Bruno stores your collections on your computer's filesystem.
+
+
+ Choose the location where you want to store this collection.
+
+
{
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
+ onChange={e => {
+ formik.setFieldValue('collectionLocation', e.target.value);
+ }}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
{formik.errors.collectionLocation}
) : null}
-
+
Browse
-
-
- Folder Name
-
-
-
- {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
- {formik.errors.collectionFolderName}
- ) : null}
+ {formik.values.collectionName?.trim()?.length > 0 && (
+
+
+
+ Folder Name
+
+
+ The name of the folder used to store the collection.
+
+
+ You can choose a folder name different from your collection's name or one compatible with filesystem rules.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+ {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
+
{formik.errors.collectionFolderName}
+ ) : null}
+
+ )}
diff --git a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js b/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
index 4335bc235..d238fd206 100644
--- a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
+++ b/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
@@ -8,7 +8,7 @@ import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme/index';
let posthogClient = null;
-const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
+const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
const getPosthogClient = () => {
if (posthogClient) {
return posthogClient;
@@ -85,7 +85,7 @@ const GoldenEdition = ({ onClose }) => {
});
};
- const goldenEditonIndividuals = [
+ const goldenEditionIndividuals = [
'Inbuilt Bru File Explorer',
'Visual Git (Like Gitlens for Vscode)',
'GRPC, Websocket, SocketIO, MQTT',
@@ -97,7 +97,7 @@ const GoldenEdition = ({ onClose }) => {
'Custom Themes'
];
- const goldenEditonOrganizations = [
+ const goldenEditionOrganizations = [
'Centralized License Management',
'Integration with Secret Managers',
'Private Collection Registry',
@@ -179,7 +179,7 @@ const GoldenEdition = ({ onClose }) => {
{pricingOption === 'individuals' ? (
<>
- {goldenEditonIndividuals.map((item, index) => (
+ {goldenEditionIndividuals.map((item, index) => (
{item}
@@ -192,7 +192,7 @@ const GoldenEdition = ({ onClose }) => {
Everything in the Individual Plan
- {goldenEditonOrganizations.map((item, index) => (
+ {goldenEditionOrganizations.map((item, index) => (
{item}
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
index d829baf10..0b71125c8 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
@@ -1,42 +1,43 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
+import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
-import importPostmanCollection from 'utils/importers/postman-collection';
+import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
+import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
- const [options, setOptions] = useState({
- enablePostmanTranslations: {
- enabled: true,
- label: 'Auto translate postman scripts',
- subLabel:
- "When enabled, Bruno will try as best to translate the scripts from the imported collection to Bruno's format."
- }
- });
+ const [isLoading, setIsLoading] = useState(false)
+
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
- .catch((err) => toastError(err, 'Import collection failed'));
+ .catch((err) => toastError(err, 'Import collection failed'))
};
+
const handleImportPostmanCollection = () => {
- importPostmanCollection(options)
- .then(({ collection, translationLog }) => {
- handleSubmit({ collection, translationLog });
+ fileDialog({ accept: 'application/json' })
+ .then((...args) => {
+ setIsLoading(true);
+ return readFile(...args);
})
- .catch((err) => toastError(err, 'Postman Import collection failed'));
- };
+ .then((collection) => postmanToBruno(collection))
+ .then((collection) => handleSubmit({ collection }))
+ .catch((err) => toastError(err, 'Postman Import collection failed'))
+ .finally(() => setIsLoading(false));
+ }
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
- .catch((err) => toastError(err, 'Insomnia Import collection failed'));
+ .catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const handleImportOpenapiCollection = () => {
@@ -44,66 +45,82 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.then(({ collection }) => {
handleSubmit({ collection });
})
- .catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
- };
- const toggleOptions = (event, optionKey) => {
- setOptions({
- ...options,
- [optionKey]: {
- ...options[optionKey],
- enabled: !options[optionKey].enabled
- }
- });
+ .catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
+
const CollectionButton = ({ children, className, onClick }) => {
return (
{children}
);
};
- return (
-
-
-
Select the type of your existing collection :
-
- Bruno Collection
- Postman Collection
- Insomnia Collection
- OpenAPI V3 Spec
-
-
- {Object.entries(options || {}).map(([key, option]) => (
-
-
- toggleOptions(e, key)}
- className="h-3.5 w-3.5 rounded border-zinc-300 dark:ring-offset-zinc-800 bg-transparent text-indigo-600 dark:text-indigo-500 focus:ring-indigo-600 dark:focus:ring-indigo-500"
- />
-
-
-
- {option.label}
-
-
-
-
- ))}
+
+ const FullscreenLoader = () => {
+ const [loadingMessage, setLoadingMessage] = useState('');
+
+ // Messages to cycle through while loading
+ const loadingMessages = [
+ 'Processing collection...',
+ 'Analyzing requests...',
+ 'Translating scripts...',
+ 'Preparing collection...',
+ 'Almost done...'
+ ];
+
+
+ // Cycle through loading messages for better UX
+ useEffect(() => {
+ if (!isLoading) return;
+
+ let messageIndex = 0;
+ const interval = setInterval(() => {
+ messageIndex = (messageIndex + 1) % loadingMessages.length;
+ setLoadingMessage(loadingMessages[messageIndex]);
+ }, 2000);
+
+ setLoadingMessage(loadingMessages[0]);
+
+ return () => clearInterval(interval);
+ }, [isLoading]);
+
+ return (
+
+
+
+
+ {loadingMessage}
+
+
+ This may take a moment depending on the collection size
+
-
+ );
+ };
+
+ return (
+ <>
+ {isLoading &&
}
+ {!isLoading && (
+
+
+
Select the type of your existing collection :
+
+ Bruno Collection
+ Postman Collection
+ Insomnia Collection
+ OpenAPI V3 Spec
+
+
+
+ )}
+ >
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
index 96fade0db..7c4e9f83f 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
@@ -4,107 +4,8 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
-import { IconAlertTriangle, IconArrowRight, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons';
-import toast from 'react-hot-toast';
-const TranslationLog = ({ translationLog }) => {
- const [showDetails, setShowDetails] = useState(false);
- const preventSetShowDetails = (e) => {
- e.stopPropagation();
- e.preventDefault();
- setShowDetails(!showDetails);
- };
- const copyClipboard = (e, value) => {
- e.stopPropagation();
- e.preventDefault();
- navigator.clipboard.writeText(value);
- toast.success('Copied to clipboard');
- };
- return (
-
-
-
-
-
-
-
-
- Warning: Some commands were not translated.{' '}
-
-
-
-
-
preventSetShowDetails(e)}
- className="flex w-fit items-center rounded px-2.5 py-1 mt-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
- >
- See details
- {showDetails ? : }
-
- {showDetails && (
-
-
- Impacted Collections: {Object.keys(translationLog || {}).length}
-
-
- Impacted Lines:{' '}
- {Object.values(translationLog || {}).reduce(
- (acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
- 0
- )}
-
-
- The numbers after 'script' and 'test' indicate the line numbers of incomplete translations.
-
-
- {Object.entries(translationLog || {}).map(([name, value]) => (
-
-
-
- {name}
-
-
- {value.script && (
-
-
- test :
- {value.script.map((scriptValue, index) => (
-
- {scriptValue}
- {index < value.script.length - 1 && <> - >}
-
- ))}
-
-
- )}
- {value.test && (
-
-
test :
- {value.test.map((testValue, index) => (
-
- {testValue}
- {index < value.test.length - 1 && <> - >}
-
- ))}
-
- )}
-
-
- ))}
-
-
copyClipboard(e, JSON.stringify(translationLog))}
- >
-
-
-
- )}
-
- );
-};
-
-const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, translationLog }) => {
+const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
const inputRef = useRef();
const dispatch = useDispatch();
@@ -117,7 +18,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
collectionLocation: Yup.string()
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
- .required('name is required')
+ .required('Location is required')
}),
onSubmit: (values) => {
handleSubmit(values.collectionLocation);
@@ -126,7 +27,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
- formik.setFieldValue('collectionLocation', dirPath);
+ if (typeof dirPath === 'string' && dirPath.length > 0) {
+ formik.setFieldValue('collectionLocation', dirPath);
+ }
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
@@ -144,15 +47,12 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
return (
-
+ e.preventDefault()}>
Name
{collectionName}
- {translationLog && Object.keys(translationLog).length > 0 && (
-
- )}
<>
Location
@@ -161,14 +61,16 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
id="collection-location"
type="text"
name="collectionLocation"
- readOnly={true}
- className="block textbox mt-2 w-full"
+ className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
+ onChange={e => {
+ formik.setFieldValue('collectionLocation', e.target.value);
+ }}
/>
>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
index 934a3bd29..83c243653 100644
--- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
@@ -1,38 +1,62 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import * as Yup from 'yup';
+import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
+import { IconArrowBackUp, IconEdit} from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import PathDisplay from 'components/PathDisplay/index';
+import Help from 'components/Help';
+import Dropdown from "components/Dropdown";
+import { IconCaretDown } from "@tabler/icons";
+import StyledWrapper from './StyledWrapper';
-const NewFolder = ({ collection, item, onClose }) => {
+const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- folderName: ''
+ folderName: '',
+ directoryName: ''
},
validationSchema: Yup.object({
folderName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
- .required('name is required')
+ .required('name is required'),
+ directoryName: Yup.string()
+ .trim()
+ .min(1, 'must be at least 1 character')
+ .required('foldername is required')
+ .test('is-valid-folder-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.test({
name: 'folderName',
message: 'The folder name "environments" at the root of the collection is reserved in bruno',
test: (value) => {
- if (item && item.uid) {
- return true;
- }
+ if (item?.uid) return true;
return value && !value.trim().toLowerCase().includes('environments');
}
})
}),
onSubmit: (values) => {
- dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null))
- .then(() => onClose())
+ dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
+ .then(() => {
+ toast.success('New folder created!');
+ onClose();
+ })
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder'));
}
});
@@ -43,34 +67,139 @@ const NewFolder = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
-
- Folder Name
-
-
- {formik.touched.folderName && formik.errors.folderName ? (
-
{formik.errors.folderName}
- ) : null}
-
-
-
+
+
+
+
+
+ Folder Name
+
+ {
+ formik.setFieldValue('folderName', e.target.value);
+ !isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
+ }}
+ value={formik.values.folderName || ''}
+ />
+ {formik.touched.folderName && formik.errors.folderName ? (
+ {formik.errors.folderName}
+ ) : null}
+
+ {showFilesystemName && (
+
+
+
+ Folder Name (on filesystem)
+
+
+ You can choose to save the folder as a different name on your file system versus what is displayed in the app.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ): (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+
+
+ ) : (
+
+ )}
+ {formik.touched.directoryName && formik.errors.directoryName ? (
+
{formik.errors.directoryName}
+ ) : null}
+
+ )}
+
+
+
} placement="bottom-start">
+
{
+ dropdownTippyRef.current.hide();
+ toggleShowFilesystemName(!showFilesystemName);
+ }}
+ >
+ {showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Create
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
index 9845bd2ef..338fb2e60 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
@@ -7,28 +7,23 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
-
.method-selector {
min-width: 80px;
}
}
-
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
-
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
-
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
-
&:focus {
outline: none !important;
box-shadow: none !important;
@@ -39,6 +34,20 @@ const StyledWrapper = styled.div`
textarea.curl-command {
min-height: 150px;
}
+ .dropdown {
+ width: fit-content;
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
+
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
`;
-export default StyledWrapper;
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
index 8d8125e94..ec8f5dfda 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
@@ -1,24 +1,72 @@
-import React, { useRef, useEffect, useCallback } from 'react';
+import React, { useRef, useEffect, useCallback, forwardRef, useState } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
+import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
-import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl';
+import { IconArrowBackUp, IconCaretDown, IconEdit } from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import Dropdown from 'components/Dropdown';
+import PathDisplay from 'components/PathDisplay';
+import Portal from 'components/Portal';
+import Help from 'components/Help';
+import StyledWrapper from './StyledWrapper';
-const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
+const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+
+ const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
+ const [curlRequestTypeDetected, setCurlRequestTypeDetected] = useState(null);
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
+ const advancedDropdownTippyRef = useRef();
+ const onAdvancedDropdownCreate = (ref) => (advancedDropdownTippyRef.current = ref);
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+ {curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"}
+
+
+ );
+ });
+
+ // This function analyzes a given cURL command string and determines whether the request is a GraphQL or HTTP request.
+ const identifyCurlRequestType = (url, headers, body) => {
+ if (url.endsWith('/graphql')) {
+ setCurlRequestTypeDetected('graphql-request');
+ return;
+ }
+
+ const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
+ if (contentType && contentType.includes('application/graphql')) {
+ setCurlRequestTypeDetected('graphql-request');
+ return;
+ }
+
+ setCurlRequestTypeDetected('http-request');
+ };
+
+ const curlRequestTypeChange = (type) => {
+ setCurlRequestTypeDetected(type);
+ };
+
+ const [isEditing, toggleEditing] = useState(false);
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
@@ -44,6 +92,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
enableReinitialize: true,
initialValues: {
requestName: '',
+ filename: '',
requestType: getRequestType(collectionPresets),
requestUrl: collectionPresets.requestUrl || '',
requestMethod: 'GET',
@@ -53,15 +102,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
- .required('name is required')
- .test({
- name: 'requestName',
- message: `The request names - collection and folder is reserved in bruno`,
- test: (value) => {
- const trimmedValue = value ? value.trim().toLowerCase() : '';
- return !['collection', 'folder'].includes(trimmedValue);
- }
- }),
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .trim()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
+ .required('filename is required')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value)),
curlCommand: Yup.string().when('requestType', {
is: (requestType) => requestType === 'from-curl',
then: Yup.string()
@@ -81,17 +133,18 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
newEphemeralHttpRequest({
uid: uid,
requestName: values.requestName,
+ filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
- collectionUid: collection.uid
+ collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -99,33 +152,42 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (values.requestType === 'from-curl') {
- const request = getRequestFromCurlCommand(values.curlCommand);
+ const request = getRequestFromCurlCommand(values.curlCommand, curlRequestTypeDetected);
dispatch(
newHttpRequest({
requestName: values.requestName,
- requestType: 'http-request',
+ filename: values.filename,
+ requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
- body: request.body
+ body: request.body,
+ auth: request.auth
})
)
- .then(() => onClose())
+ .then(() => {
+ toast.success('New request created!');
+ onClose()
+ })
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else {
dispatch(
newHttpRequest({
requestName: values.requestName,
+ filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
- .then(() => onClose())
+ .then(() => {
+ toast.success('New request created!');
+ onClose()
+ })
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}
}
@@ -151,6 +213,12 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
formik.setFieldValue('requestType', 'from-curl');
formik.setFieldValue('curlCommand', pastedData);
+ // Identify the request type
+ const request = getRequestFromCurlCommand(pastedData);
+ if (request) {
+ identifyCurlRequestType(request.url, request.headers, request.body);
+ }
+
// Prevent the default paste behavior to avoid pasting into the textarea
event.preventDefault();
}
@@ -158,136 +226,289 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
[formik]
);
+ const handleCurlCommandChange = (event) => {
+ formik.handleChange(event);
+
+ if (event.target.name === 'curlCommand') {
+ const curlCommand = event.target.value;
+ const request = getRequestFromCurlCommand(curlCommand);
+ if (request) {
+ identifyCurlRequestType(request.url, request.headers, request.body);
+ }
+ }
+ };
+
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
+
return (
-
-
-
-
-
- Type
-
-
-
-
-
- Name
-
-
- {formik.touched.requestName && formik.errors.requestName ? (
-
{formik.errors.requestName}
- ) : null}
-
- {formik.values.requestType !== 'from-curl' ? (
- <>
-
-
- URL
+
+
+
+ HTTP
-
+
+
+ Request Name
+
+
{
+ formik.setFieldValue('requestName', e.target.value);
+ !isEditing && formik.setFieldValue('filename', sanitizeName(e.target.value));
+ }}
+ value={formik.values.requestName || ''}
+ />
+ {formik.touched.requestName && formik.errors.requestName ? (
+
{formik.errors.requestName}
+ ) : null}
+
+ {showFilesystemName && (
+
+
+
+ File Name (on filesystem)
+
+
+ Bruno saves each request as a file in your collection's folder.
+
+
+ You can choose a file name different from your request's name or one compatible with filesystem rules.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
/>
-
-
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+ .bru
+
+ ) : (
+
-
- {formik.touched.requestUrl && formik.errors.requestUrl ? (
-
{formik.errors.requestUrl}
+ )}
+ {formik.touched.filename && formik.errors.filename ? (
+
{formik.errors.filename}
) : null}
- >
- ) : (
-
-
- cURL Command
-
-
- {formik.touched.curlCommand && formik.errors.curlCommand ? (
-
{formik.errors.curlCommand}
- ) : null}
+ )}
+ {formik.values.requestType !== 'from-curl' ? (
+ <>
+
+
+ URL
+
+
+
+ formik.setFieldValue('requestMethod', val)}
+ />
+
+
+
+
+
+ {formik.touched.requestUrl && formik.errors.requestUrl ? (
+
{formik.errors.requestUrl}
+ ) : null}
+
+ >
+ ) : (
+
+
+
+ cURL Command
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ curlRequestTypeChange('http-request');
+ }}
+ >
+ HTTP
+
+
{
+ dropdownTippyRef.current.hide();
+ curlRequestTypeChange('graphql-request');
+ }}
+ >
+ GraphQL
+
+
+
+
+ {formik.touched.curlCommand && formik.errors.curlCommand ? (
+
{formik.errors.curlCommand}
+ ) : null}
+
+ )}
+
+
+
} placement="bottom-start">
+
{
+ advancedDropdownTippyRef.current.hide();
+ toggleShowFilesystemName(!showFilesystemName);
+ }}
+ >
+ {showFilesystemName ? 'Hide Filesystem Name' : 'Show Filesystem Name'}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Create
+
+
+
- )}
-
-
-
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
index 7962da07f..65badf3aa 100644
--- a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
+++ b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js
@@ -11,30 +11,34 @@ import { useDispatch } from 'react-redux';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import { multiLineMsg } from "utils/common";
+import { formatIpcError } from "utils/common/error";
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
- const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const dispatch = useDispatch();
const { ipcRenderer } = window;
- const handleImportCollection = ({ collection, translationLog }) => {
+ const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
- if (translationLog) {
- setImportedTranslationLog(translationLog);
- }
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (collectionLocation) => {
- dispatch(importCollection(importedCollection, collectionLocation));
- setImportCollectionLocationModalOpen(false);
- setImportedCollection(null);
- toast.success('Collection imported successfully');
+ dispatch(importCollection(importedCollection, collectionLocation))
+ .then(() => {
+ setImportCollectionLocationModalOpen(false);
+ setImportedCollection(null);
+ toast.success('Collection imported successfully');
+ })
+ .catch((err) => {
+ console.error(err);
+ toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
+ });
};
const menuDropdownTippyRef = useRef();
@@ -68,23 +72,18 @@ const TitleBar = () => {
{importCollectionLocationModalOpen ? (
setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
) : null}
-
-
-
-
+
+
+
+
bruno
-
+
} placement="bottom-start">
{
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const preferencesOpen = useSelector((state) => state.app.showPreferences);
- const [goldenEditonOpen, setGoldenEditonOpen] = useState(false);
-
+ const [goldenEditionOpen, setGoldenEditionOpen] = useState(false);
+ const { version } = useApp();
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const [cookiesOpen, setCookiesOpen] = useState(false);
@@ -82,10 +83,43 @@ const Sidebar = () => {
return (
- {goldenEditonOpen && setGoldenEditonOpen(false)} />}
+ {goldenEditionOpen && (
+ {
+ setGoldenEditionOpen(false);
+ document.querySelector('[data-trigger="golden-edition"]').focus();
+ }}
+ aria-modal="true"
+ role="dialog"
+ aria-labelledby="golden-edition-title"
+ aria-describedby="golden-edition-description"
+ />
+ )}
- {preferencesOpen &&
dispatch(showPreferences(false))} />}
- {cookiesOpen && setCookiesOpen(false)} />}
+ {preferencesOpen && (
+ {
+ dispatch(showPreferences(false));
+ document.querySelector('[data-trigger="preferences"]').focus();
+ }}
+ aria-modal="true"
+ role="dialog"
+ aria-labelledby="preferences-title"
+ aria-describedby="preferences-description"
+ />
+ )}
+ {cookiesOpen && (
+ {
+ setCookiesOpen(false);
+ document.querySelector('[data-trigger="cookies"]').focus();
+ }}
+ aria-modal="true"
+ role="dialog"
+ aria-labelledby="cookies-title"
+ aria-describedby="cookies-description"
+ />
+ )}
@@ -95,28 +129,50 @@ const Sidebar = () => {
{/* This will get moved to home page */}
@@ -129,11 +185,12 @@ const Sidebar = () => {
Star
*/}
-
v1.18.0
+
v{version}
+
diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js
index 7a8f996ee..e34085657 100644
--- a/packages/bruno-app/src/components/SingleLineEditor/index.js
+++ b/packages/bruno-app/src/components/SingleLineEditor/index.js
@@ -1,11 +1,12 @@
import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
-import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
+import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
+import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@@ -20,36 +21,42 @@ class SingleLineEditor extends Component {
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
+
+ this.state = {
+ maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
+ };
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
+ const variables = getAllVariables(this.props.collection, this.props.item);
+
+ const runHandler = () => {
+ if (this.props.onRun) {
+ this.props.onRun();
+ }
+ };
+ const saveHandler = () => {
+ if (this.props.onSave) {
+ this.props.onSave();
+ }
+ };
+ const noopHandler = () => {};
+
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: {
- variables: getAllVariables(this.props.collection)
+ variables
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
- Enter: () => {
- if (this.props.onRun) {
- this.props.onRun();
- }
- },
- 'Ctrl-Enter': () => {
- if (this.props.onRun) {
- this.props.onRun();
- }
- },
- 'Cmd-Enter': () => {
- if (this.props.onRun) {
- this.props.onRun();
- }
- },
+ Enter: runHandler,
+ 'Ctrl-Enter': runHandler,
+ 'Cmd-Enter': runHandler,
'Alt-Enter': () => {
if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n');
@@ -58,23 +65,11 @@ class SingleLineEditor extends Component {
this.props.onRun();
}
},
- 'Shift-Enter': () => {
- if (this.props.onRun) {
- this.props.onRun();
- }
- },
- 'Cmd-S': () => {
- if (this.props.onSave) {
- this.props.onSave();
- }
- },
- 'Ctrl-S': () => {
- if (this.props.onSave) {
- this.props.onSave();
- }
- },
- 'Cmd-F': () => {},
- 'Ctrl-F': () => {},
+ 'Shift-Enter': runHandler,
+ 'Cmd-S': saveHandler,
+ 'Ctrl-S': saveHandler,
+ 'Cmd-F': noopHandler,
+ 'Ctrl-F': noopHandler,
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
@@ -82,21 +77,37 @@ class SingleLineEditor extends Component {
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
- if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
+ if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.key !== 'Enter') {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
- this.editor.setValue(String(this.props.value) || '');
+ this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
- this.addOverlay();
+ this.addOverlay(variables);
+ this._enableMaskedEditor(this.props.isSecret);
+ this.setState({ maskInput: this.props.isSecret });
}
+ /** Enable or disable masking the rendered content of the editor */
+ _enableMaskedEditor = (enabled) => {
+ if (typeof enabled !== 'boolean') return;
+
+ console.log('Enabling masked editor: ' + enabled);
+ if (enabled == true) {
+ if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
+ this.maskedEditor.enable();
+ } else {
+ this.maskedEditor?.disable();
+ this.maskedEditor = null;
+ }
+ };
+
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
- if (this.props.onChange) {
+ if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
}
@@ -108,17 +119,23 @@ class SingleLineEditor extends Component {
// event loop.
this.ignoreChangeEvent = true;
- let variables = getAllVariables(this.props.collection);
+ let variables = getAllVariables(this.props.collection, this.props.item);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
- this.addOverlay();
+ this.addOverlay(variables);
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
- this.editor.setValue(String(this.props.value) || '');
+ this.editor.setValue(String(this.props.value ?? ''));
+ }
+ if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
+ // If the secret flag has changed, update the editor to reflect the change
+ this._enableMaskedEditor(this.props.isSecret);
+ // also set the maskInput flag to the new value
+ this.setState({ maskInput: this.props.isSecret });
}
this.ignoreChangeEvent = false;
}
@@ -127,16 +144,41 @@ class SingleLineEditor extends Component {
this.editor.getWrapperElement().remove();
}
- addOverlay = () => {
- let variables = getAllVariables(this.props.collection);
+ addOverlay = (variables) => {
this.variables = variables;
-
- defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
+ defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true);
this.editor.setOption('mode', 'brunovariables');
};
+ toggleVisibleSecret = () => {
+ const isVisible = !this.state.maskInput;
+ this.setState({ maskInput: isVisible });
+ this._enableMaskedEditor(isVisible);
+ };
+
+ /**
+ * @brief Eye icon to show/hide the secret value
+ * @returns ReactComponent The eye icon
+ */
+ secretEye = (isSecret) => {
+ return isSecret === true ? (
+ this.toggleVisibleSecret()}>
+ {this.state.maskInput === true ? (
+
+ ) : (
+
+ )}
+
+ ) : null;
+ };
+
render() {
- return ;
+ return (
+
+
+ {this.secretEye(this.props.isSecret)}
+
+ );
}
}
export default SingleLineEditor;
diff --git a/packages/bruno-app/src/components/StopWatch/index.js b/packages/bruno-app/src/components/StopWatch/index.js
index e2b069532..5954106d3 100644
--- a/packages/bruno-app/src/components/StopWatch/index.js
+++ b/packages/bruno-app/src/components/StopWatch/index.js
@@ -1,30 +1,25 @@
import React, { useState, useEffect } from 'react';
-const StopWatch = ({ requestTimestamp }) => {
- const [milliseconds, setMilliseconds] = useState(0);
-
- const tickInterval = 200;
- const tick = () => {
- setMilliseconds(milliseconds + tickInterval);
- };
-
+const StopWatch = ({ startTime }) => {
+ const [currentTime, setCurrentTime] = useState(Date.now());
+
useEffect(() => {
- let timerID = setInterval(() => tick(), tickInterval);
- return () => {
- clearInterval(timerID);
- };
- });
-
- useEffect(() => {
- setMilliseconds(Date.now() - requestTimestamp);
- }, [requestTimestamp]);
-
- if (milliseconds < 1000) {
- return 'Loading...';
- }
-
- let seconds = milliseconds / 1000;
+ if (!startTime) return;
+
+ const intervalId = setInterval(() => {
+ setCurrentTime(Date.now());
+ }, 100);
+
+ return () => clearInterval(intervalId);
+ }, [startTime]);
+
+ if (!startTime) return Loading... ;
+
+ const elapsedTime = currentTime - startTime;
+ if (elapsedTime < 250) return Loading... ;
+
+ const seconds = elapsedTime / 1000;
return {seconds.toFixed(1)}s ;
};
-export default StopWatch;
+export default React.memo(StopWatch);
diff --git a/packages/bruno-app/src/components/Table/StyledWrapper.js b/packages/bruno-app/src/components/Table/StyledWrapper.js
new file mode 100644
index 000000000..eeead4ed2
--- /dev/null
+++ b/packages/bruno-app/src/components/Table/StyledWrapper.js
@@ -0,0 +1,64 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ table {
+ width: 100%;
+ display: grid;
+ overflow-y: hidden;
+ overflow-x: auto;
+
+ // for icon hover
+ position: inherit;
+ left: -4px;
+ padding-left: 4px;
+ padding-right: 4px;
+
+ grid-template-columns: ${({ columns }) =>
+ columns?.[0]?.width
+ ? columns.map((col) => `${col?.width}`).join(' ')
+ : columns.map((col) => `${100 / columns.length}%`).join(' ')};
+ }
+
+ table thead,
+ table tbody,
+ table tr {
+ display: contents;
+ }
+
+ table th {
+ position: relative;
+ border-bottom: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+ }
+
+ table tr td {
+ padding: 0.5rem;
+ text-align: left;
+ border-top: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+ border-right: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+ }
+
+ tr {
+ transition: transform 0.2s ease-in-out;
+ }
+
+ tr.dragging {
+ opacity: 0.5;
+ }
+
+ tr.hovered {
+ transform: translateY(10px); /* Adjust the value as needed for the animation effect */
+ }
+
+ table tr th {
+ padding: 0.5rem;
+ text-align: left;
+ border-top: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+ border-right: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+
+ &:nth-child(1) {
+ border-left: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Table/index.js b/packages/bruno-app/src/components/Table/index.js
new file mode 100644
index 000000000..7c9b48d7d
--- /dev/null
+++ b/packages/bruno-app/src/components/Table/index.js
@@ -0,0 +1,110 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import StyledWrapper from './StyledWrapper';
+
+const Table = ({ minColumnWidth = 1, headers = [], children }) => {
+ const [activeColumnIndex, setActiveColumnIndex] = useState(null);
+ const tableRef = useRef(null);
+
+ const columns = headers?.map((item) => ({
+ ...item,
+ ref: useRef()
+ }));
+
+ const updateDivHeights = () => {
+ if (tableRef.current) {
+ const height = tableRef.current.offsetHeight;
+ columns.forEach((col) => {
+ if (col.ref.current) {
+ col.ref.current.querySelector('.resizer').style.height = `${height}px`;
+ }
+ });
+ }
+ };
+
+ useEffect(() => {
+ updateDivHeights();
+ window.addEventListener('resize', updateDivHeights);
+
+ return () => {
+ window.removeEventListener('resize', updateDivHeights);
+ };
+ }, [columns]);
+
+ useEffect(() => {
+ if (tableRef.current) {
+ const observer = new MutationObserver(updateDivHeights);
+ observer.observe(tableRef.current, { childList: true, subtree: true });
+
+ return () => {
+ observer.disconnect();
+ };
+ }
+ }, [columns]);
+
+ const handleMouseDown = (index) => (e) => {
+ setActiveColumnIndex(index);
+ };
+
+ const handleMouseMove = useCallback(
+ (e) => {
+ const gridColumns = columns.map((col, i) => {
+ if (i === activeColumnIndex) {
+ const width = e.clientX - col.ref?.current?.getBoundingClientRect()?.left;
+
+ if (width >= minColumnWidth) {
+ return `${width}px`;
+ }
+ }
+ return `${col.ref.current.offsetWidth}px`;
+ });
+
+ tableRef.current.style.gridTemplateColumns = `${gridColumns.join(' ')}`;
+ },
+ [activeColumnIndex, columns, minColumnWidth]
+ );
+
+ const removeListeners = useCallback(() => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', removeListeners);
+ }, [handleMouseMove]);
+
+ const handleMouseUp = useCallback(() => {
+ setActiveColumnIndex(null);
+ removeListeners?.();
+ }, [removeListeners]);
+
+ useEffect(() => {
+ if (activeColumnIndex !== null) {
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ }
+ return () => {
+ removeListeners();
+ };
+ }, [activeColumnIndex, handleMouseMove, handleMouseUp, removeListeners]);
+
+ return (
+
+
+
+
+
+ {columns.map(({ ref, name }, i) => (
+
+ {name}
+
+
+ ))}
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Table;
diff --git a/packages/bruno-app/src/components/Toast/StyledWrapper.js b/packages/bruno-app/src/components/Toast/StyledWrapper.js
deleted file mode 100644
index e06a2a058..000000000
--- a/packages/bruno-app/src/components/Toast/StyledWrapper.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- &.bruno-toast {
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- display: flex;
- justify-content: center;
- }
-
- .bruno-toast-card {
- -webkit-animation-duration: 0.85s;
- animation-duration: 0.85s;
- -webkit-animation-delay: 0.1s;
- animation-delay: 0.1s;
- border-radius: var(--border-radius);
- position: relative;
- max-width: calc(100% - var(--spacing-base-unit));
- margin: 3vh 10vw;
-
- animation: fade-and-slide-in-from-top 0.5s forwards cubic-bezier(0.19, 1, 0.22, 1);
- }
-
- .notification-toast-content {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- padding: 5px;
- border-radius: 4px;
- }
-
- .alert {
- position: relative;
- padding: 0.25rem 0.75rem;
- border: 1px solid transparent;
- border-radius: 0.25rem;
- display: flex;
- justify-content: space-between;
- }
-
- .alert-error {
- color: #721c24;
- background-color: #f8d7da;
- border-color: #f5c6cb;
- }
-
- .alert-info {
- color: #004085;
- background-color: #cce5ff;
- border-color: #b8daff;
- }
-
- .alert-warning {
- color: #856404;
- background-color: #fff3cd;
- border-color: #ffeeba;
- }
-
- .alert-success {
- color: #155724;
- background-color: #d4edda;
- border-color: #c3e6cb;
- }
-
- .closeToast {
- cursor: pointer;
- padding-left: 10px;
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/Toast/index.js b/packages/bruno-app/src/components/Toast/index.js
deleted file mode 100644
index 243b9a364..000000000
--- a/packages/bruno-app/src/components/Toast/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { useEffect } from 'react';
-import StyledWrapper from './StyledWrapper';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faTimes } from '@fortawesome/free-solid-svg-icons';
-
-const ToastContent = ({ type, text, handleClose }) => (
-
-);
-
-const Toast = ({ text, type, duration, handleClose }) => {
- let lifetime = duration ? duration : 3000;
-
- useEffect(() => {
- if (text) {
- setTimeout(handleClose, lifetime);
- }
- }, [text]);
-
- return (
-
-
-
-
-
- );
-};
-
-export default Toast;
diff --git a/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js
new file mode 100644
index 000000000..d4216860a
--- /dev/null
+++ b/packages/bruno-app/src/components/ToggleSwitch/StyledWrapper.js
@@ -0,0 +1,91 @@
+import styled from 'styled-components';
+
+const switchSizes = {
+ '2xs': { width: 32, height: 16, buttonSize: 14 },
+ xs: { width: 40, height: 20, buttonSize: 18 },
+ s: { width: 44, height: 22, buttonSize: 20 },
+ m: { width: 50, height: 24, buttonSize: 22 }, // default size
+ l: { width: 56, height: 28, buttonSize: 26 },
+ xl: { width: 64, height: 32, buttonSize: 30 },
+ '2xl': { width: 72, height: 36, buttonSize: 34 }
+};
+
+const getSizeValues = (size = 'm') => switchSizes[size] || switchSizes.m;
+
+export const Switch = styled.div`
+ position: relative;
+ display: inline-block;
+ width: ${(props) => getSizeValues(props.size).width}px;
+ height: ${(props) => getSizeValues(props.size).height}px;
+ border-radius: ${(props) => getSizeValues(props.size).height}px;
+`;
+
+export const Checkbox = styled.input`
+ opacity: 0;
+ width: 0;
+ height: 0;
+
+ &:checked + label div {
+ background-color: ${(props) => props.theme.textLink};
+ }
+
+ &:checked + label div:before {
+ transform: translateX(${(props) => getSizeValues(props.size).width - getSizeValues(props.size).buttonSize - 2}px);
+ }
+`;
+
+export const Label = styled.label`
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ background-color: ${(props) => props.theme.input.bg};
+ border-radius: 24px;
+
+ div {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: ${(props) => props.theme.colors.text.muted};
+ border-radius: 24px;
+ transition: transform 0.2s;
+ }
+`;
+
+export const Inner = styled.div`
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ right: 2px;
+ bottom: 2px;
+ background-color: #fafafa;
+ transition: 0.4s;
+ border-radius: ${(props) => getSizeValues(props.size).height - 2}px;
+`;
+
+export const SwitchButton = styled.div`
+ position: absolute;
+ height: ${(props) => getSizeValues(props.size).buttonSize}px;
+ width: ${(props) => getSizeValues(props.size).buttonSize}px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: 0.4s;
+ border-radius: 50%;
+
+ &:before {
+ content: '';
+ position: absolute;
+ height: ${(props) => getSizeValues(props.size).buttonSize - 2}px;
+ width: ${(props) => getSizeValues(props.size).buttonSize - 2}px;
+ background-color: white;
+ top: 2px;
+ left: 2px;
+ transition: 0.4s;
+ border-radius: 50%;
+ }
+`;
diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js
new file mode 100644
index 000000000..cf386a347
--- /dev/null
+++ b/packages/bruno-app/src/components/ToggleSwitch/index.js
@@ -0,0 +1,15 @@
+import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
+
+const ToggleSwitch = ({ isOn, handleToggle, size = 'm', ...props }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default ToggleSwitch;
diff --git a/packages/bruno-app/src/components/ToolHint/StyledWrapper.js b/packages/bruno-app/src/components/ToolHint/StyledWrapper.js
new file mode 100644
index 000000000..8cbe85f38
--- /dev/null
+++ b/packages/bruno-app/src/components/ToolHint/StyledWrapper.js
@@ -0,0 +1,8 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ background-color: ${(props) => props.theme.sidebar.badge};
+ color: ${(props) => props.theme.text};
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/ToolHint/index.js b/packages/bruno-app/src/components/ToolHint/index.js
new file mode 100644
index 000000000..3d559625e
--- /dev/null
+++ b/packages/bruno-app/src/components/ToolHint/index.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import { Tooltip as ReactToolHint } from 'react-tooltip';
+import StyledWrapper from './StyledWrapper';
+import { useTheme } from 'providers/Theme';
+
+const ToolHint = ({
+ text,
+ toolhintId,
+ children,
+ tooltipStyle = {},
+ place = 'top',
+ offset,
+ theme = null,
+ className = ''
+}) => {
+ const { theme: contextTheme } = useTheme();
+ const appliedTheme = theme || contextTheme;
+
+ const toolhintBackgroundColor = appliedTheme?.sidebar.badge.bg || 'black';
+ const toolhintTextColor = appliedTheme?.text || 'white';
+
+ const combinedToolhintStyle = {
+ ...tooltipStyle,
+ fontSize: '0.75rem',
+ padding: '0.25rem 0.5rem',
+ zIndex: 9999,
+ backgroundColor: toolhintBackgroundColor,
+ color: toolhintTextColor
+ };
+
+ return (
+ <>
+ {children}
+
+
+
+ >
+ );
+};
+
+export default ToolHint;
diff --git a/packages/bruno-app/src/components/VariablesEditor/index.js b/packages/bruno-app/src/components/VariablesEditor/index.js
index 980a8c5c3..a06b6a1ff 100644
--- a/packages/bruno-app/src/components/VariablesEditor/index.js
+++ b/packages/bruno-app/src/components/VariablesEditor/index.js
@@ -15,7 +15,7 @@ const KeyValueExplorer = ({ data = [], theme }) => {
setShowSecret(!showSecret)} />
- {data.map((envVar) => (
+ {data.toSorted((a, b) => a.name.localeCompare(b.name)).map((envVar) => (
{envVar.name}
@@ -62,10 +62,10 @@ const EnvVariables = ({ collection, theme }) => {
);
};
-const CollectionVariables = ({ collection, theme }) => {
- const collectionVariablesFound = Object.keys(collection.collectionVariables).length > 0;
+const RuntimeVariables = ({ collection, theme }) => {
+ const runtimeVariablesFound = Object.keys(collection.runtimeVariables).length > 0;
- const collectionVariableArray = Object.entries(collection.collectionVariables).map(([name, value]) => ({
+ const runtimeVariableArray = Object.entries(collection.runtimeVariables).map(([name, value]) => ({
name,
value,
secret: false
@@ -73,11 +73,11 @@ const CollectionVariables = ({ collection, theme }) => {
return (
<>
- Collection Variables
- {collectionVariablesFound ? (
-
+ Runtime Variables
+ {runtimeVariablesFound ? (
+
) : (
- No collection variables found
+ No runtime variables found
)}
>
);
@@ -90,13 +90,13 @@ const VariablesEditor = ({ collection }) => {
return (
-
+
- Note: As of today, collection variables can only be set via the API -{' '}
- getVar() and setVar() .
- In the next release, we will add a UI to set and modify collection variables.
+ Note: As of today, runtime variables can only be set via the API - getVar() {' '}
+ and setVar() .
+ In the next release, we will add a UI to set and modify runtime variables.
);
diff --git a/packages/bruno-app/src/components/Welcome/index.js b/packages/bruno-app/src/components/Welcome/index.js
index 7f60377b6..5931a535b 100644
--- a/packages/bruno-app/src/components/Welcome/index.js
+++ b/packages/bruno-app/src/components/Welcome/index.js
@@ -1,6 +1,7 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
+import { useTranslation } from 'react-i18next';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
@@ -12,32 +13,34 @@ import StyledWrapper from './StyledWrapper';
const Welcome = () => {
const dispatch = useDispatch();
+ const { t } = useTranslation();
const [importedCollection, setImportedCollection] = useState(null);
- const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const handleOpenCollection = () => {
- dispatch(openCollection()).catch(
- (err) => console.log(err) && toast.error('An error occurred while opening the collection')
- );
+ dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
};
- const handleImportCollection = ({ collection, translationLog }) => {
+ const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
- if (translationLog) {
- setImportedTranslationLog(translationLog);
- }
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (collectionLocation) => {
- dispatch(importCollection(importedCollection, collectionLocation));
- setImportCollectionLocationModalOpen(false);
- setImportedCollection(null);
- toast.success('Collection imported successfully');
+ dispatch(importCollection(importedCollection, collectionLocation))
+ .then(() => {
+ setImportCollectionLocationModalOpen(false);
+ setImportedCollection(null);
+ toast.success(t('WELCOME.COLLECTION_IMPORT_SUCCESS'));
+ })
+ .catch((err) => {
+ setImportCollectionLocationModalOpen(false);
+ console.error(err);
+ toast.error(t('WELCOME.COLLECTION_IMPORT_ERROR'));
+ });
};
return (
@@ -48,57 +51,84 @@ const Welcome = () => {
) : null}
{importCollectionLocationModalOpen ? (
setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
) : null}
-
+
bruno
-
Opensource IDE for exploring and testing APIs
+
{t('WELCOME.ABOUT_BRUNO')}
-
Collections
+
{t('COMMON.COLLECTIONS')}
-
setCreateCollectionModalOpen(true)}>
-
+ setCreateCollectionModalOpen(true)}
+ aria-label={t('WELCOME.CREATE_COLLECTION')}
+ >
+
- Create Collection
+ {t('WELCOME.CREATE_COLLECTION')}
-
-
-
- Open Collection
-
-
setImportCollectionModalOpen(true)}>
-
+
+
+
+
+ {t('WELCOME.OPEN_COLLECTION')}
+
+
+ setImportCollectionModalOpen(true)}
+ aria-label={t('WELCOME.IMPORT_COLLECTION')}
+ >
+
- Import Collection
+ {t('WELCOME.IMPORT_COLLECTION')}
-
+
-
Links
+
{t('WELCOME.LINKS')}
diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js
index c2b167813..c63f23c17 100644
--- a/packages/bruno-app/src/globalStyles.js
+++ b/packages/bruno-app/src/globalStyles.js
@@ -100,6 +100,11 @@ const GlobalStyle = createGlobalStyle`
}
}
+ input::placeholder {
+ color: ${(props) => props.theme.input.placeholder.color};
+ opacity: ${(props) => props.theme.input.placeholder.opacity};
+ }
+
@keyframes fade-in {
from {
opacity: 0;
@@ -168,7 +173,6 @@ const GlobalStyle = createGlobalStyle`
// (macos scrollbar styling is the ideal style reference)
@media not all and (pointer: coarse) {
* {
- scrollbar-width: thin;
scrollbar-color: ${(props) => props.theme.scrollbar.color};
}
@@ -227,6 +231,11 @@ const GlobalStyle = createGlobalStyle`
.CodeMirror-brunoVarInfo p {
margin: 1em 0;
}
+
+ .CodeMirror-hint-active {
+ background: #08f !important;
+ color: #fff !important;
+ }
`;
export default GlobalStyle;
diff --git a/packages/bruno-app/src/hooks/useFocusTrap/index.js b/packages/bruno-app/src/hooks/useFocusTrap/index.js
new file mode 100644
index 000000000..760603d21
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useFocusTrap/index.js
@@ -0,0 +1,53 @@
+import { useEffect, useRef } from 'react';
+
+const useFocusTrap = (modalRef) => {
+
+ // refer to this implementation for modal focus: https://stackoverflow.com/a/38865836
+ const focusableSelector = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex]:not([tabindex="-1"]), *[contenteditable]';
+
+ useEffect(() => {
+ const modalElement = modalRef.current;
+ if (!modalElement) return;
+
+ const focusableElements = Array.from(document.querySelectorAll(focusableSelector));
+ const modalFocusableElements = Array.from(modalElement.querySelectorAll(focusableSelector));
+ const elementsToHide = focusableElements.filter(el => !modalFocusableElements.includes(el));
+
+ // Hide elements outside the modal
+ elementsToHide.forEach(el => {
+ const originalTabIndex = el.getAttribute('tabindex');
+ el.setAttribute('data-tabindex', originalTabIndex || 'inline');
+ el.setAttribute('tabindex', -1);
+ });
+
+ // Set focus to the first focusable element in the modal
+ const firstElement = modalFocusableElements[0];
+ const lastElement = modalFocusableElements[modalFocusableElements.length - 1];
+
+ const handleKeyDown = (event) => {
+ if (event.key === 'Tab') {
+ if (event.shiftKey && document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement.focus();
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
+ }
+ }
+ };
+
+ modalElement.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ modalElement.removeEventListener('keydown', handleKeyDown);
+
+ // Restore original tabindex values
+ elementsToHide.forEach(el => {
+ const originalTabIndex = el.getAttribute('data-tabindex');
+ el.setAttribute('tabindex', originalTabIndex === 'inline' ? '' : originalTabIndex);
+ });
+ };
+ }, [modalRef]);
+};
+
+export default useFocusTrap;
diff --git a/packages/bruno-app/src/hooks/useOnClickOutside/index.js b/packages/bruno-app/src/hooks/useOnClickOutside/index.js
index 25cd5b426..2dbef75f2 100644
--- a/packages/bruno-app/src/hooks/useOnClickOutside/index.js
+++ b/packages/bruno-app/src/hooks/useOnClickOutside/index.js
@@ -5,7 +5,7 @@ const useOnClickOutside = (ref, handler) => {
useEffect(
() => {
const listener = (event) => {
- // Do nothing if clicking ref's element or descendent elements
+ // Do nothing if clicking ref's element or descendant elements
if (!ref.current || ref.current.contains(event.target)) {
return;
}
diff --git a/packages/bruno-app/src/i18n/index.js b/packages/bruno-app/src/i18n/index.js
new file mode 100644
index 000000000..26e89695f
--- /dev/null
+++ b/packages/bruno-app/src/i18n/index.js
@@ -0,0 +1,24 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import translationEn from './translation/en.json';
+
+const resources = {
+ en: {
+ translation: translationEn,
+ },
+};
+
+i18n
+ .use(initReactI18next) // passes i18n down to react-i18next
+ .init({
+ resources,
+ lng: 'en', // Use "en" as the default language. "cimode" can be used to debug / show translation placeholder
+
+ ns: 'translation', // Use translation as the default Namespace that will be loaded by default
+
+ interpolation: {
+ escapeValue: false // react already safes from xss
+ }
+ });
+
+export default i18n;
diff --git a/packages/bruno-app/src/i18n/translation/en.json b/packages/bruno-app/src/i18n/translation/en.json
new file mode 100644
index 000000000..7dda41e42
--- /dev/null
+++ b/packages/bruno-app/src/i18n/translation/en.json
@@ -0,0 +1,20 @@
+{
+ "COMMON": {
+ "COLLECTIONS": "Collections",
+ "DOCUMENTATION": "Documentation",
+ "REPORT_ISSUES": "Report Issues",
+ "GITHUB": "GitHub",
+ "DISCORD": "Discord",
+ "TWITTER": "Twitter"
+ },
+ "WELCOME": {
+ "ABOUT_BRUNO": "Opensource IDE for exploring and testing APIs",
+ "LINKS": "Links",
+ "CREATE_COLLECTION": "Create Collection",
+ "OPEN_COLLECTION": "Open Collection",
+ "IMPORT_COLLECTION": "Import Collection",
+ "COLLECTION_IMPORT_SUCCESS": "Collection imported successfully",
+ "COLLECTION_IMPORT_ERROR": "An error occurred while importing the collection. Check the logs for more information.",
+ "COLLECTION_OPEN_ERROR": "An error occurred while opening the collection"
+ }
+}
diff --git a/packages/bruno-app/src/index.js b/packages/bruno-app/src/index.js
new file mode 100644
index 000000000..36b1d0bc6
--- /dev/null
+++ b/packages/bruno-app/src/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './pages/index';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+
+const rootElement = document.getElementById('root');
+
+if (rootElement) {
+ const root = ReactDOM.createRoot(rootElement);
+ root.render(
+
+
+
+
+
+ );
+}
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 71e24dcfa..ed5539dbb 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -10,7 +10,7 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');
@@ -31,6 +31,7 @@ if (!SERVER_RENDERED) {
require('codemirror/addon/search/jump-to-line');
require('codemirror/addon/search/search');
require('codemirror/addon/search/searchcursor');
+ require('codemirror/addon/display/placeholder');
require('codemirror/keymap/sublime');
require('codemirror-graphql/hint');
diff --git a/packages/bruno-app/src/pages/_app.js b/packages/bruno-app/src/pages/Main.js
similarity index 54%
rename from packages/bruno-app/src/pages/_app.js
rename to packages/bruno-app/src/pages/Main.js
index cf8b3683e..ba7b3289e 100644
--- a/packages/bruno-app/src/pages/_app.js
+++ b/packages/bruno-app/src/pages/Main.js
@@ -8,38 +8,24 @@ import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
-import '../styles/app.scss';
import '../styles/globals.css';
import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
import 'react-tooltip/dist/react-tooltip.css';
import '@usebruno/graphql-docs/dist/esm/index.css';
+import '@fontsource/inter/100.css';
+import '@fontsource/inter/200.css';
+import '@fontsource/inter/300.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
+import '@fontsource/inter/700.css';
+import '@fontsource/inter/800.css';
+import '@fontsource/inter/900.css';
+import { setupPolyfills } from 'utils/common/setupPolyfills';
+setupPolyfills();
-function SafeHydrate({ children }) {
- return
{typeof window === 'undefined' ? null : children}
;
-}
-
-function NoSsr({ children }) {
- const SERVER_RENDERED = typeof navigator === 'undefined';
-
- if (SERVER_RENDERED) {
- return null;
- }
-
- return <>{children}>;
-}
-
-function MyApp({ Component, pageProps }) {
- const [domLoaded, setDomLoaded] = useState(false);
-
- useEffect(() => {
- setDomLoaded(true);
- }, []);
-
- if (!domLoaded) {
- return null;
- }
-
+function Main({ children }) {
if (!window.ipcRenderer) {
return (
@@ -55,23 +41,21 @@ function MyApp({ Component, pageProps }) {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
);
}
-export default MyApp;
+export default Main;
+
+
diff --git a/packages/bruno-app/src/pages/_document.js b/packages/bruno-app/src/pages/_document.js
deleted file mode 100644
index 131fc50dd..000000000
--- a/packages/bruno-app/src/pages/_document.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import Document, { Html, Head, Main, NextScript } from 'next/document';
-import { ServerStyleSheet } from 'styled-components';
-
-export default class MyDocument extends Document {
- static async getInitialProps(ctx) {
- const sheet = new ServerStyleSheet();
- const originalRenderPage = ctx.renderPage;
-
- try {
- ctx.renderPage = () =>
- originalRenderPage({
- enhanceApp: (App) => (props) => sheet.collectStyles(
)
- });
-
- const initialProps = await Document.getInitialProps(ctx);
- return {
- ...initialProps,
- styles: (
- <>
- {initialProps.styles}
- {sheet.getStyleElement()}
- >
- )
- };
- } finally {
- sheet.seal();
- }
- }
-
- render() {
- return (
-
-
-
-
-
-
-
-
-
- );
- }
-}
diff --git a/packages/bruno-app/src/pages/index.js b/packages/bruno-app/src/pages/index.js
index 1567ef2c4..8d609b8e6 100644
--- a/packages/bruno-app/src/pages/index.js
+++ b/packages/bruno-app/src/pages/index.js
@@ -1,19 +1,16 @@
-import Head from 'next/head';
import Bruno from './Bruno';
import GlobalStyle from '../globalStyles';
+import '../i18n';
+import Main from './Main';
-export default function Home() {
+export default function App() {
return (
-
-
bruno
-
-
-
-
-
-
+
+
+
+
);
diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js
index c54d53867..b06d1d3a8 100644
--- a/packages/bruno-app/src/providers/App/index.js
+++ b/packages/bruno-app/src/providers/App/index.js
@@ -1,15 +1,17 @@
import React, { useEffect } from 'react';
+import { get } from 'lodash';
import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import StyledWrapper from './StyledWrapper';
+import { version } from '../../../package.json';
export const AppContext = React.createContext();
export const AppProvider = (props) => {
- useTelemetry();
+ useTelemetry({ version });
useIpcEvents();
const dispatch = useDispatch();
@@ -18,6 +20,13 @@ export const AppProvider = (props) => {
dispatch(refreshScreenWidth());
}, []);
+ useEffect(() => {
+ const platform = get(navigator, 'platform', '');
+ if(platform && platform.toLowerCase().indexOf('mac') > -1) {
+ document.body.classList.add('os-mac');
+ }
+ }, []);
+
useEffect(() => {
const handleResize = () => {
dispatch(refreshScreenWidth());
@@ -29,7 +38,7 @@ export const AppProvider = (props) => {
}, []);
return (
-
+
{props.children}
@@ -38,4 +47,12 @@ export const AppProvider = (props) => {
);
};
+export const useApp = () => {
+ const context = React.useContext(AppContext);
+ if (!context) {
+ throw new Error('useApp must be used within an AppProvider');
+ }
+ return context;
+};
+
export default AppProvider;
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 467a8582c..1828a5890 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -1,5 +1,10 @@
import { useEffect } from 'react';
-import { showPreferences, updateCookies, updatePreferences } from 'providers/ReduxStore/slices/app';
+import {
+ showPreferences,
+ updateCookies,
+ updatePreferences,
+ updateSystemProxyEnvVariables
+} from 'providers/ReduxStore/slices/app';
import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
@@ -14,10 +19,12 @@ import {
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
-import { collectionAddEnvFileEvent, openCollectionEvent } from 'providers/ReduxStore/slices/collections/actions';
+import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
+import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
+import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index';
const useIpcEvents = () => {
const dispatch = useDispatch();
@@ -104,6 +111,10 @@ const useIpcEvents = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
});
+ const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
+ dispatch(globalEnvironmentsUpdateEvent(val));
+ });
+
const removeCollectionRenamedListener = ipcRenderer.on('main:collection-renamed', (val) => {
dispatch(collectionRenamedEvent(val));
});
@@ -136,16 +147,39 @@ const useIpcEvents = () => {
dispatch(updatePreferences(val));
});
+ const removeSystemProxyEnvUpdatesListener = ipcRenderer.on('main:load-system-proxy-env', (val) => {
+ dispatch(updateSystemProxyEnvVariables(val));
+ });
+
const removeCookieUpdateListener = ipcRenderer.on('main:cookies-update', (val) => {
dispatch(updateCookies(val));
});
+ const removeGlobalEnvironmentsUpdatesListener = ipcRenderer.on('main:load-global-environments', (val) => {
+ dispatch(updateGlobalEnvironments(val));
+ });
+
+ const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => {
+ dispatch(hydrateCollectionWithUiStateSnapshot(val));
+ });
+
+ const removeCollectionOauth2CredentialsUpdatesListener = ipcRenderer.on('main:credentials-update', (val) => {
+ const payload = {
+ ...val,
+ itemUid: val.itemUid || null,
+ folderUid: val.folderUid || null,
+ credentialsId: val.credentialsId || 'credentials'
+ };
+ dispatch(collectionAddOauth2CredentialsByUrl(payload));
+ });
+
return () => {
removeCollectionTreeUpdateListener();
removeOpenCollectionListener();
removeCollectionAlreadyOpenedListener();
removeDisplayErrorListener();
removeScriptEnvUpdateListener();
+ removeGlobalEnvironmentVariablesUpdateListener();
removeCollectionRenamedListener();
removeRunFolderEventListener();
removeRunRequestEventListener();
@@ -155,6 +189,10 @@ const useIpcEvents = () => {
removeShowPreferencesListener();
removePreferencesUpdatesListener();
removeCookieUpdateListener();
+ removeSystemProxyEnvUpdatesListener();
+ removeGlobalEnvironmentsUpdatesListener();
+ removeSnapshotHydrationListener();
+ removeCollectionOauth2CredentialsUpdatesListener();
};
}, [isElectron]);
};
diff --git a/packages/bruno-app/src/providers/App/useTelemetry.js b/packages/bruno-app/src/providers/App/useTelemetry.js
index f973dd967..712a6efb7 100644
--- a/packages/bruno-app/src/providers/App/useTelemetry.js
+++ b/packages/bruno-app/src/providers/App/useTelemetry.js
@@ -7,21 +7,19 @@
*/
import { useEffect } from 'react';
-import getConfig from 'next/config';
import { PostHog } from 'posthog-node';
import platformLib from 'platform';
import { uuid } from 'utils/common';
-const { publicRuntimeConfig } = getConfig();
-const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
+const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
let posthogClient = null;
const isPlaywrightTestRunning = () => {
- return publicRuntimeConfig.PLAYWRIGHT ? true : false;
+ return process.env.PLAYWRIGHT ? true : false;
};
const isDevEnv = () => {
- return publicRuntimeConfig.ENV === 'dev';
+ return import.meta.env.MODE === 'development';
};
const getPosthogClient = () => {
@@ -44,7 +42,7 @@ const getAnonymousTrackingId = () => {
return id;
};
-const trackStart = () => {
+const trackStart = (version) => {
if (isPlaywrightTestRunning()) {
return;
}
@@ -60,16 +58,18 @@ const trackStart = () => {
event: 'start',
properties: {
os: platformLib.os.family,
- version: '1.18.0'
+ version: version
}
});
};
-const useTelemetry = () => {
+const useTelemetry = ({ version }) => {
useEffect(() => {
- trackStart();
- setInterval(trackStart, 24 * 60 * 60 * 1000);
- }, []);
+ if (posthogApiKey && posthogApiKey.length) {
+ trackStart(version);
+ setInterval(trackStart, 24 * 60 * 60 * 1000);
+ }
+ }, [posthogApiKey]);
};
export default useTelemetry;
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 8b0503b1c..f756fb86f 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -3,13 +3,18 @@ import toast from 'react-hot-toast';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import { useSelector, useDispatch } from 'react-redux';
-import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
-import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import {
+ sendRequest,
+ saveRequest,
+ saveCollectionRoot,
+ saveFolderRoot
+} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
-import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
+import { getKeyBindingsForActionAllOS } from './keyMappings';
export const HotkeysContext = React.createContext();
@@ -19,19 +24,9 @@ export const HotkeysProvider = (props) => {
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
- const [showSaveRequestModal, setShowSaveRequestModal] = useState(false);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
- const getCurrentCollectionItems = () => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
- if (activeTab) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
-
- return collection ? collection.items : [];
- }
- };
-
const getCurrentCollection = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
@@ -43,7 +38,7 @@ export const HotkeysProvider = (props) => {
// save hotkey
useEffect(() => {
- Mousetrap.bind(['command+s', 'ctrl+s'], (e) => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
if (isEnvironmentSettingsModalOpen) {
console.log('todo: save environment settings');
} else {
@@ -53,10 +48,13 @@ export const HotkeysProvider = (props) => {
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
- dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
- } else {
- // todo: when ephermal requests go live
- // setShowSaveRequestModal(true);
+ if (activeTab.type === 'folder-settings') {
+ dispatch(saveFolderRoot(collection.uid, item.uid));
+ } else {
+ dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
+ }
+ } else if (activeTab.type === 'collection-settings') {
+ dispatch(saveCollectionRoot(collection.uid));
}
}
}
@@ -66,13 +64,13 @@ export const HotkeysProvider = (props) => {
});
return () => {
- Mousetrap.unbind(['command+s', 'ctrl+s']);
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
};
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen]);
// send request (ctrl/cmd + enter)
useEffect(() => {
- Mousetrap.bind(['command+enter', 'ctrl+enter'], (e) => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
@@ -93,13 +91,13 @@ export const HotkeysProvider = (props) => {
});
return () => {
- Mousetrap.unbind(['command+enter', 'ctrl+enter']);
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
};
}, [activeTabUid, tabs, saveRequest, collections]);
// edit environments (ctrl/cmd + e)
useEffect(() => {
- Mousetrap.bind(['command+e', 'ctrl+e'], (e) => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
@@ -113,13 +111,13 @@ export const HotkeysProvider = (props) => {
});
return () => {
- Mousetrap.unbind(['command+e', 'ctrl+e']);
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
};
}, [activeTabUid, tabs, collections, setShowEnvSettingsModal]);
// new request (ctrl/cmd + b)
useEffect(() => {
- Mousetrap.bind(['command+b', 'ctrl+b'], (e) => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
@@ -133,13 +131,13 @@ export const HotkeysProvider = (props) => {
});
return () => {
- Mousetrap.unbind(['command+b', 'ctrl+b']);
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// close tab hotkey
useEffect(() => {
- Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
dispatch(
closeTabs({
tabUids: [activeTabUid]
@@ -150,20 +148,78 @@ export const HotkeysProvider = (props) => {
});
return () => {
- Mousetrap.unbind(['command+w', 'ctrl+w']);
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
}, [activeTabUid]);
+ // Switch to the previous tab
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
+ dispatch(
+ switchTab({
+ direction: 'pageup'
+ })
+ );
+
+ return false; // this stops the event bubbling
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
+ };
+ }, [dispatch]);
+
+ // Switch to the next tab
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
+ dispatch(
+ switchTab({
+ direction: 'pagedown'
+ })
+ );
+
+ return false; // this stops the event bubbling
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
+ };
+ }, [dispatch]);
+
+ // Close all tabs
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
+ const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ if (activeTab) {
+ const collection = findCollectionByUid(collections, activeTab.collectionUid);
+
+ if (collection) {
+ const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
+ dispatch(
+ closeTabs({
+ tabUids: tabUids
+ })
+ );
+ }
+ }
+
+ return false; // this stops the event bubbling
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
+ };
+ }, [activeTabUid, tabs, collections, dispatch]);
+
+ const currentCollection = getCurrentCollection();
+
return (
- {showSaveRequestModal && (
- setShowSaveRequestModal(false)} />
- )}
{showEnvSettingsModal && (
- setShowEnvSettingsModal(false)} />
+ setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
- setShowNewRequestModal(false)} />
+ setShowNewRequestModal(false)} />
)}
{props.children}
diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
new file mode 100644
index 000000000..05ad4531b
--- /dev/null
+++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
@@ -0,0 +1,60 @@
+const KeyMapping = {
+ save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
+ sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
+ editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
+ newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
+ closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
+ openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
+ minimizeWindow: {
+ mac: 'command+Shift+Q',
+ windows: 'control+Shift+Q',
+ name: 'Minimize Window'
+ },
+ switchToPreviousTab: {
+ mac: 'command+pageup',
+ windows: 'ctrl+pageup',
+ name: 'Switch to Previous Tab'
+ },
+ switchToNextTab: {
+ mac: 'command+pagedown',
+ windows: 'ctrl+pagedown',
+ name: 'Switch to Next Tab'
+ },
+ closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }
+};
+
+/**
+ * Retrieves the key bindings for a specific operating system.
+ *
+ * @param {string} os - The operating system (e.g., 'mac', 'windows').
+ * @returns {Object} An object containing the key bindings for the specified OS.
+ */
+export const getKeyBindingsForOS = (os) => {
+ const keyBindings = {};
+ for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
+ if (keys[os]) {
+ keyBindings[action] = {
+ keys: keys[os],
+ name
+ };
+ }
+ }
+ return keyBindings;
+};
+
+/**
+ * Retrieves the key bindings for a specific action across all operating systems.
+ *
+ * @param {string} action - The action for which to retrieve key bindings.
+ * @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
+ */
+export const getKeyBindingsForActionAllOS = (action) => {
+ const actionBindings = KeyMapping[action];
+
+ if (!actionBindings) {
+ console.warn(`Action "${action}" not found in KeyMapping.`);
+ return null;
+ }
+
+ return [actionBindings.mac, actionBindings.windows];
+};
diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js
index f8ae75d64..e02886582 100644
--- a/packages/bruno-app/src/providers/ReduxStore/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/index.js
@@ -1,4 +1,3 @@
-import getConfig from 'next/config';
import { configureStore } from '@reduxjs/toolkit';
import tasksMiddleware from './middlewares/tasks/middleware';
import debugMiddleware from './middlewares/debug/middleware';
@@ -6,13 +5,14 @@ import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
+import globalEnvironmentsReducer from './slices/global-environments';
+import { draftDetectMiddleware } from './middlewares/draft/middleware';
-const { publicRuntimeConfig } = getConfig();
const isDevEnv = () => {
- return publicRuntimeConfig.ENV === 'dev';
+ return import.meta.env.MODE === 'development';
};
-let middleware = [tasksMiddleware.middleware];
+let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware];
}
@@ -22,7 +22,8 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
- notifications: notificationsReducer
+ notifications: notificationsReducer,
+ globalEnvironments: globalEnvironmentsReducer
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js
new file mode 100644
index 000000000..4b8f39443
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js
@@ -0,0 +1,56 @@
+import { handleMakeTabParmanent } from "./utils";
+
+const actionsToIntercept = [
+ 'collections/requestUrlChanged',
+ 'collections/updateAuth',
+ 'collections/addQueryParam',
+ 'collections/moveQueryParam',
+ 'collections/updateQueryParam',
+ 'collections/deleteQueryParam',
+ 'collections/updatePathParam',
+ 'collections/addRequestHeader',
+ 'collections/updateRequestHeader',
+ 'collections/deleteRequestHeader',
+ 'collections/moveRequestHeader',
+ 'collections/addFormUrlEncodedParam',
+ 'collections/updateFormUrlEncodedParam',
+ 'collections/deleteFormUrlEncodedParam',
+ 'collections/moveFormUrlEncodedParam',
+ 'collections/addMultipartFormParam',
+ 'collections/updateMultipartFormParam',
+ 'collections/deleteMultipartFormParam',
+ 'collections/moveMultipartFormParam',
+ 'collections/updateRequestAuthMode',
+ 'collections/updateRequestBodyMode',
+ 'collections/updateRequestBody',
+ 'collections/updateRequestGraphqlQuery',
+ 'collections/updateRequestGraphqlVariables',
+ 'collections/updateRequestScript',
+ 'collections/updateResponseScript',
+ 'collections/updateRequestTests',
+ 'collections/updateRequestMethod',
+ 'collections/addAssertion',
+ 'collections/updateAssertion',
+ 'collections/deleteAssertion',
+ 'collections/moveAssertion',
+ 'collections/addVar',
+ 'collections/updateVar',
+ 'collections/deleteVar',
+ 'collections/moveVar',
+ 'collections/addFolderHeader',
+ 'collections/updateFolderHeader',
+ 'collections/deleteFolderHeader',
+ 'collections/addFolderVar',
+ 'collections/updateFolderVar',
+ 'collections/deleteFolderVar',
+ 'collections/updateRequestDocs',
+ 'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
+];
+
+export const draftDetectMiddleware = ({ dispatch, getState }) => (next) => (action) => {
+ if (actionsToIntercept.includes(action.type)) {
+ const state = getState();
+ handleMakeTabParmanent(state, action, dispatch);
+ }
+ return next(action);
+};
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/utils.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/utils.js
new file mode 100644
index 000000000..ab84ccaf4
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/utils.js
@@ -0,0 +1,21 @@
+import { makeTabPermanent } from "providers/ReduxStore/slices/tabs";
+import { findCollectionByUid, findItemInCollection } from "utils/collections/index";
+import find from 'lodash/find';
+
+function handleMakeTabParmanent(state, action, dispatch) {
+ const tabs = state.tabs.tabs;
+ const activeTabUid = state.tabs.activeTabUid;
+ const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ const itemUid = action.payload.itemUid || action.payload.folderUid
+ const collection = findCollectionByUid(state.collections.collections, action.payload.collectionUid);
+ if (collection) {
+ const item = findItemInCollection(collection, itemUid);
+ if (item && focusedTab.preview == true) {
+ dispatch(makeTabPermanent({ uid: itemUid }));
+ }
+ }
+}
+
+export {
+ handleMakeTabParmanent
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index beb3d1fcd..f19c51101 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -18,7 +18,7 @@ const initialState = {
filePath: null
},
keepDefaultCaCertificates: {
- enabled: false
+ enabled: true
},
timeout: 0
},
@@ -27,7 +27,8 @@ const initialState = {
}
},
cookies: [],
- taskQueue: []
+ taskQueue: [],
+ systemProxyEnvVariables: {}
};
export const appSlice = createSlice({
@@ -72,6 +73,9 @@ export const appSlice = createSlice({
},
removeAllTasksFromQueue: (state) => {
state.taskQueue = [];
+ },
+ updateSystemProxyEnvVariables: (state, action) => {
+ state.systemProxyEnvVariables = action.payload;
}
}
});
@@ -89,7 +93,8 @@ export const {
updateCookies,
insertTaskIntoQueue,
removeTaskFromQueue,
- removeAllTasksFromQueue
+ removeAllTasksFromQueue,
+ updateSystemProxyEnvVariables
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
@@ -117,6 +122,44 @@ export const deleteCookiesForDomain = (domain) => (dispatch, getState) => {
});
};
+export const deleteCookie = (domain, path, cookieKey) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+
+ ipcRenderer.invoke('renderer:delete-cookie', domain, path, cookieKey).then(resolve).catch(reject);
+ });
+};
+
+export const addCookie = (domain, cookie) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+
+ ipcRenderer.invoke('renderer:add-cookie', domain, cookie).then(resolve).catch(reject);
+ });
+};
+
+export const modifyCookie = (domain, oldCookie, cookie) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+
+ ipcRenderer.invoke('renderer:modify-cookie', domain, oldCookie, cookie).then(resolve).catch(reject);
+ });
+};
+
+export const getParsedCookie = (cookieStr) => () => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:get-parsed-cookie', cookieStr).then(resolve).catch(reject);
+ });
+};
+
+export const createCookieString = (cookieObj) => () => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:create-cookie-string', cookieObj).then(resolve).catch(reject);
+ });
+};
+
export const completeQuitFlow = () => (dispatch, getState) => {
const { ipcRenderer } = window;
return ipcRenderer.invoke('main:complete-quit-flow');
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 526b43a1e..bbbc9a61e 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -3,8 +3,9 @@ import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
import get from 'lodash/get';
+import set from 'lodash/set';
import trim from 'lodash/trim';
-import path from 'path';
+import path from 'utils/common/path';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import {
@@ -12,17 +13,14 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findParentItemInCollection,
- getItemsToResequence,
isItemAFolder,
- isItemARequest,
- moveCollectionItem,
- moveCollectionItemToRootOfCollection,
refreshUidsInItem,
+ isItemARequest,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
-import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
+import { callIpc } from 'utils/common/ipc';
import {
collectionAddEnvFileEvent as _collectionAddEnvFileEvent,
@@ -30,17 +28,26 @@ import {
removeCollection as _removeCollection,
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
+ updateCollectionMountStatus,
+ moveCollection,
requestCancelled,
resetRunResults,
responseReceived,
- updateLastAction
+ updateLastAction,
+ setCollectionSecurityConfig,
+ collectionAddOauth2CredentialsByUrl,
+ collectionClearOauth2CredentialsByUrl,
+ initRunRequestEvent
} from './index';
import { each } from 'lodash';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
-import { parseQueryParams, splitOnFirst } from 'utils/url/index';
+import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
+import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
+import { sanitizeName } from 'utils/common/regex';
+import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -50,7 +57,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
if (!collection) {
return reject(new Error('Collection not found'));
}
-
+ const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
});
};
@@ -143,20 +150,58 @@ export const saveCollectionRoot = (collectionUid) => (dispatch, getState) => {
});
};
-export const sendCollectionOauth2Request = (collectionUid) => (dispatch, getState) => {
+export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ const folder = findItemInCollection(collection, folderUid);
+
+ return new Promise((resolve, reject) => {
+ if (!collection) {
+ return reject(new Error('Collection not found'));
+ }
+
+ if (!folder) {
+ return reject(new Error('Folder not found'));
+ }
+
+ const { ipcRenderer } = window;
+
+ const folderData = {
+ name: folder.name,
+ pathname: folder.pathname,
+ root: folder.root
+ };
+
+ ipcRenderer
+ .invoke('renderer:save-folder-root', folderData)
+ .then(() => toast.success('Folder Settings saved successfully'))
+ .then(resolve)
+ .catch((err) => {
+ toast.error('Failed to save folder settings!');
+ reject(err);
+ });
+ });
+};
+
+export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch, getState) => {
+ const state = getState();
+ const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
- const collectionCopy = cloneDeep(collection);
+ let collectionCopy = cloneDeep(collection);
+
+ // add selected global env variables to the collection object
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
- _sendCollectionOauth2Request(collection, environment, collectionCopy.collectionVariables)
+ _sendCollectionOauth2Request(collectionCopy, environment, collectionCopy.runtimeVariables)
.then((response) => {
if (response?.data?.error) {
toast.error(response?.data?.error);
@@ -174,25 +219,49 @@ export const sendCollectionOauth2Request = (collectionUid) => (dispatch, getStat
export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
+ const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ const itemUid = item?.uid;
- return new Promise((resolve, reject) => {
+ return new Promise(async (resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
+
+ let collectionCopy = cloneDeep(collection);
- const itemCopy = cloneDeep(item || {});
- const collectionCopy = cloneDeep(collection);
+ const itemCopy = cloneDeep(item);
- const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
+ const requestUid = uuid();
+ itemCopy.requestUid = requestUid;
- sendNetworkRequest(itemCopy, collection, environment, collectionCopy.collectionVariables)
+ await dispatch(initRunRequestEvent({
+ requestUid,
+ itemUid,
+ collectionUid
+ }));
+
+ // add selected global env variables to the collection object
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
+
+ const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
+ sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
.then((response) => {
+ // Ensure any timestamps in the response are converted to numbers
+ const serializedResponse = {
+ ...response,
+ timeline: response.timeline?.map(entry => ({
+ ...entry,
+ timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
+ }))
+ };
+
return dispatch(
responseReceived({
- itemUid: item.uid,
- collectionUid: collectionUid,
- response: response
+ itemUid,
+ collectionUid,
+ response: serializedResponse
})
);
})
@@ -202,8 +271,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
console.log('>> request cancelled');
dispatch(
responseReceived({
- itemUid: item.uid,
- collectionUid: collectionUid,
+ itemUid,
+ collectionUid,
response: null
})
);
@@ -220,8 +289,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
dispatch(
responseReceived({
- itemUid: item.uid,
- collectionUid: collectionUid,
+ itemUid,
+ collectionUid,
response: errorResponse
})
);
@@ -246,8 +315,9 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
};
-export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
+export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
const state = getState();
+ const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@@ -255,7 +325,12 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
return reject(new Error('Collection not found'));
}
- const collectionCopy = cloneDeep(collection);
+ let collectionCopy = cloneDeep(collection);
+
+ // add selected global env variables to the collection object
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
+
const folder = findItemInCollection(collectionCopy, folderUid);
if (folderUid && !folder) {
@@ -270,14 +345,16 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
})
);
+ const { ipcRenderer } = window;
ipcRenderer
.invoke(
'renderer:run-collection-folder',
folder,
collectionCopy,
environment,
- collectionCopy.collectionVariables,
- recursive
+ collectionCopy.runtimeVariables,
+ recursive,
+ delay
)
.then(resolve)
.catch((err) => {
@@ -287,9 +364,11 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
});
};
-export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getState) => {
+export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
+ const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => {
if (!collection) {
@@ -299,15 +378,37 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (!itemUid) {
const folderWithSameNameExists = find(
collection.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(folderName)
+ (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`;
+ const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
-
ipcRenderer
.invoke('renderer:new-folder', fullName)
- .then(() => resolve())
+ .then(async () => {
+ const folderData = {
+ name: folderName,
+ pathname: fullName,
+ root: {
+ meta: {
+ name: folderName,
+ seq: items?.length + 1
+ },
+ request: {
+ auth: {
+ mode: 'inherit'
+ }
+ }
+ }
+ };
+ ipcRenderer
+ .invoke('renderer:save-folder-root', folderData)
+ .then(resolve)
+ .catch((err) => {
+ toast.error('Failed to save folder settings!');
+ reject(err);
+ });
+ })
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -317,15 +418,38 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (currentItem) {
const folderWithSameNameExists = find(
currentItem.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(folderName)
+ (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
- const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${folderName}`;
+ const fullName = path.join(currentItem.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName)
- .then(() => resolve())
+ .then(async () => {
+ const folderData = {
+ name: folderName,
+ pathname: fullName,
+ root: {
+ meta: {
+ name: folderName,
+ seq: items?.length + 1
+ },
+ request: {
+ auth: {
+ mode: 'inherit'
+ }
+ }
+ }
+ };
+ ipcRenderer
+ .invoke('renderer:save-folder-root', folderData)
+ .then(resolve)
+ .catch((err) => {
+ toast.error('Failed to save folder settings!');
+ reject(err);
+ });
+ })
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -337,7 +461,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
});
};
-export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
+export const renameItem = ({ newName, newFilename, itemUid, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -352,22 +476,53 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
return reject(new Error('Unable to locate item'));
}
- const dirname = getDirectoryName(item.pathname);
-
- let newPathname = '';
- if (item.type === 'folder') {
- newPathname = path.join(dirname, trim(newName));
- } else {
- const filename = resolveRequestFilename(newName);
- newPathname = path.join(dirname, filename);
- }
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject);
+ const renameName = async () => {
+ return ipcRenderer.invoke('renderer:rename-item-name', { itemPath: item.pathname, newName })
+ .catch((err) => {
+ toast.error('Failed to rename the item name');
+ console.error(err);
+ throw new Error('Failed to rename the item name');
+ });
+ };
+
+ const renameFile = async () => {
+ const dirname = path.dirname(item.pathname);
+ let newPath = '';
+ if (item.type === 'folder') {
+ newPath = path.join(dirname, trim(newFilename));
+ } else {
+ const filename = resolveRequestFilename(newFilename);
+ newPath = path.join(dirname, filename);
+ }
+
+ return ipcRenderer.invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename })
+ .catch((err) => {
+ toast.error('Failed to rename the file');
+ console.error(err);
+ throw new Error('Failed to rename the file');
+ });
+ };
+
+ let renameOperation = null;
+ if (newName) renameOperation = renameName;
+ if (newFilename) renameOperation = renameFile;
+
+ if (!renameOperation) {
+ resolve();
+ }
+
+ renameOperation()
+ .then(() => {
+ toast.success('Item renamed successfully');
+ resolve();
+ })
+ .catch((err) => reject(err));
});
};
-export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
+export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -386,36 +541,44 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
const folderWithSameNameExists = find(
parentFolder.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(newName)
+ (i) => i.type === 'folder' && trim(i?.filename) === trim(newFilename)
);
if (folderWithSameNameExists) {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
}
- const collectionPath = `${parentFolder.pathname}${PATH_SEPARATOR}${newName}`;
+ set(item, 'name', newName);
+ set(item, 'filename', newFilename);
+ set(item, 'root.meta.name', newName);
+ set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
+
+ const collectionPath = path.join(parentFolder.pathname, newFilename);
+
+ const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
const parentItem = findParentItemInCollection(collectionCopy, itemUid);
- const filename = resolveRequestFilename(newName);
+ const filename = resolveRequestFilename(newFilename);
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item));
- itemToSave.name = trim(newName);
+ set(itemToSave, 'name', trim(newName));
+ set(itemToSave, 'filename', trim(filename));
if (!parentItem) {
const reqWithSameNameExists = find(
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
+ const fullPathname = path.join(collection.pathname, filename);
const { ipcRenderer } = window;
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
itemSchema
.validate(itemToSave)
- .then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
+ .then(() => ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave))
.then(resolve)
.catch(reject);
@@ -424,7 +587,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
- itemPathname: fullName
+ itemPathname: fullPathname
})
);
} else {
@@ -436,7 +599,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
- const dirname = getDirectoryName(item.pathname);
+ const dirname = path.dirname(item.pathname);
const fullName = path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
@@ -490,179 +653,117 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
-export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
+
+export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+
+ ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
+ .then(resolve)
+ .catch(reject);
+ });
+}
+
+export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
+ const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
+ const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
+ const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
+ const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
+ const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
+ const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
+ const { uid: targetItemUid } = targetItem;
+ const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
+
+ const newDirname = path.dirname(newPathname);
+ await dispatch(moveItem({
+ targetDirname: newDirname,
+ sourcePathname: draggedItemPathname
+ }));
+
+ // Update sequences in the source directory
+ if (draggedItemDirectoryItems?.length) {
+ // reorder items in the source directory
+ const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
+ const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
+ if (reorderedSourceItems?.length) {
+ await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
+ }
+ }
+
+ // Update sequences in the target directory (if dropping adjacent)
+ if (dropType === 'adjacent') {
+ const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
+
+ const draggedItemWithNewPathAndSequence = {
+ ...draggedItem,
+ pathname: newPathname,
+ seq: targetItemSequence
+ };
+
+ // draggedItem is added to the targetItem's directory
+ const reorderedTargetItems = getReorderedItemsInTargetDirectory({
+ items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
+ targetItemUid,
+ draggedItemUid
+ });
+
+ if (reorderedTargetItems?.length) {
+ await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
+ }
+ }
+ };
+
+ const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
+ const { uid: targetItemUid } = targetItem;
+ const { uid: draggedItemUid } = draggedItem;
+
+ // reorder items in the targetItem's directory
+ const reorderedItems = getReorderedItemsInTargetDirectory({
+ items: targetItemDirectoryItems,
+ targetItemUid,
+ draggedItemUid
+ });
+
+ if (reorderedItems?.length) {
+ await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
+ }
+ };
+
+ return new Promise(async (resolve, reject) => {
+ try {
+ const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
+ if (!newPathname) return;
+ if (targetItemPathname?.startsWith(draggedItemPathname)) return;
+ if (newPathname !== draggedItemPathname) {
+ await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
+ } else {
+ await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
+ }
+ resolve();
+ } catch (error) {
+ console.error(error);
+ toast.error(error?.message);
+ reject(error);
+ }
+ })
+}
+
+export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
- if (!collection) {
- return reject(new Error('Collection not found'));
- }
+ const { ipcRenderer } = window;
- const collectionCopy = cloneDeep(collection);
- const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
- const targetItem = findItemInCollection(collectionCopy, targetItemUid);
-
- if (!draggedItem) {
- return reject(new Error('Dragged item not found'));
- }
-
- if (!targetItem) {
- return reject(new Error('Target item not found'));
- }
-
- const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
- const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
- const sameParent = draggedItemParent === targetItemParent;
-
- // file item dragged onto another file item and both are in the same folder
- // this is also true when both items are at the root level
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
- moveCollectionItem(collectionCopy, draggedItem, targetItem);
- const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
-
- return ipcRenderer
- .invoke('renderer:resequence-items', itemsToResequence)
- .then(resolve)
- .catch((error) => reject(error));
- }
-
- // file item dragged onto another file item which is at the root level
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
- const draggedItemPathname = draggedItem.pathname;
- moveCollectionItem(collectionCopy, draggedItem, targetItem);
- const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
- const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
-
- return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
- .then(resolve)
- .catch((error) => reject(error));
- }
-
- // file item dragged onto another file item and both are in different folders
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
- const draggedItemPathname = draggedItem.pathname;
- moveCollectionItem(collectionCopy, draggedItem, targetItem);
- const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
- const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
-
- return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
- .then(resolve)
- .catch((error) => reject(error));
- }
-
- // file item dragged into its own folder
- if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
- return resolve();
- }
-
- // file item dragged into another folder
- if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
- const draggedItemPathname = draggedItem.pathname;
- moveCollectionItem(collectionCopy, draggedItem, targetItem);
- const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
- const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
-
- return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
- .then(resolve)
- .catch((error) => reject(error));
- }
-
- // end of the file drags, now let's handle folder drags
- // folder drags are simpler since we don't allow ordering of folders
-
- // folder dragged into its own folder
- if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
- return resolve();
- }
-
- // folder dragged into a file which is at the same level
- // this is also true when both items are at the root level
- if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
- return resolve();
- }
-
- // folder dragged into a file which is a child of the folder
- if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
- return resolve();
- }
-
- // folder dragged into a file which is at the root level
- if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
- const draggedItemPathname = draggedItem.pathname;
-
- return ipcRenderer
- .invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
- .then(resolve)
- .catch((error) => reject(error));
- }
-
- // folder dragged into another folder
- if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
- const draggedItemPathname = draggedItem.pathname;
-
- return ipcRenderer
- .invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
- .then(resolve)
- .catch((error) => reject(error));
- }
+ ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
+ .then(resolve)
+ .catch(reject);
});
-};
-
-export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
- const state = getState();
- const collection = findCollectionByUid(state.collections.collections, collectionUid);
-
- return new Promise((resolve, reject) => {
- if (!collection) {
- return reject(new Error('Collection not found'));
- }
-
- const collectionCopy = cloneDeep(collection);
- const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
- if (!draggedItem) {
- return reject(new Error('Dragged item not found'));
- }
-
- const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
- // file item is already at the root level
- if (!draggedItemParent) {
- return resolve();
- }
-
- const draggedItemPathname = draggedItem.pathname;
- moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
-
- if (isItemAFolder(draggedItem)) {
- return ipcRenderer
- .invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
- .then(resolve)
- .catch((error) => reject(error));
- } else {
- const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
- const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
-
- return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
- .then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
- .then(resolve)
- .catch((error) => reject(error));
- }
- });
-};
+}
export const newHttpRequest = (params) => (dispatch, getState) => {
- const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body } = params;
+ const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -672,15 +773,25 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
}
const parts = splitOnFirst(requestUrl, '?');
- const params = parseQueryParams(parts[1]);
- each(params, (urlParam) => {
+ const queryParams = parseQueryParams(parts[1]);
+ each(queryParams, (urlParam) => {
urlParam.enabled = true;
+ urlParam.type = 'query';
});
+ const pathParams = parsePathParams(requestUrl);
+ each(pathParams, (pathParm) => {
+ pathParams.enabled = true;
+ pathParm.type = 'path';
+ });
+
+ const params = [...queryParams, ...pathParams];
+
const item = {
uid: uuid(),
type: requestType,
name: requestName,
+ filename,
request: {
method: requestMethod,
url: requestUrl,
@@ -693,52 +804,30 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
xml: null,
sparql: null,
multipartForm: null,
- formUrlEncoded: null
+ formUrlEncoded: null,
+ file: null
+ },
+ auth: auth ?? {
+ mode: 'inherit'
}
}
};
// itemUid is null when we are creating a new request at the root level
- const filename = resolveRequestFilename(requestName);
+ const resolvedFilename = resolveRequestFilename(filename);
if (!itemUid) {
const reqWithSameNameExists = find(
collection.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
- const requestItems = filter(collection.items, (i) => i.type !== 'folder');
- item.seq = requestItems.length + 1;
+ const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
if (!reqWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
+ const fullName = path.join(collection.pathname, resolvedFilename);
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
- // task middleware will track this and open the new request in a new tab once request is created
- dispatch(
- insertTaskIntoQueue({
- uid: uuid(),
- type: 'OPEN_REQUEST',
- collectionUid,
- itemPathname: fullName
- })
- );
- } else {
- return reject(new Error('Duplicate request names are not allowed under the same folder'));
- }
- } else {
- const currentItem = findItemInCollection(collection, itemUid);
- if (currentItem) {
- const reqWithSameNameExists = find(
- currentItem.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
- );
- const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
- item.seq = requestItems.length + 1;
- if (!reqWithSameNameExists) {
- const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`;
- const { ipcRenderer } = window;
-
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
+ ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
// task middleware will track this and open the new request in a new tab once request is created
dispatch(
insertTaskIntoQueue({
@@ -748,6 +837,35 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
itemPathname: fullName
})
);
+ resolve();
+ }).catch(reject);
+ } else {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+ } else {
+ const currentItem = findItemInCollection(collection, itemUid);
+ if (currentItem) {
+ const reqWithSameNameExists = find(
+ currentItem.items,
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
+ );
+ const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+ if (!reqWithSameNameExists) {
+ const fullName = path.join(currentItem.pathname, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ }).catch(reject);
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
@@ -764,6 +882,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
+ const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(
@@ -789,16 +908,19 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
if (!collection) {
return reject(new Error('Collection not found'));
}
+
+ const sanitizedName = sanitizeName(name);
+ const { ipcRenderer } = window;
ipcRenderer
- .invoke('renderer:create-environment', collection.pathname, name, variables)
+ .invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
- payload: name
+ payload: sanitizedName
}
})
)
@@ -818,18 +940,21 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const baseEnv = findEnvironmentInCollection(collection, baseEnvUid);
if (!collection) {
- return reject(new Error('Environmnent not found'));
+ return reject(new Error('Environment not found'));
}
+ const sanitizedName = sanitizeName(name);
+
+ const { ipcRenderer } = window;
ipcRenderer
- .invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables)
+ .invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
- payload: name
+ payload: sanitizedName
}
})
)
@@ -853,12 +978,14 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
+ const sanitizedName = sanitizeName(newName);
const oldName = environment.name;
- environment.name = newName;
+ environment.name = sanitizedName;
+ const { ipcRenderer } = window;
environmentSchema
.validate(environment)
- .then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, newName))
+ .then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
.then(resolve)
.catch(reject);
});
@@ -879,6 +1006,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
+ const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-environment', collection.pathname, environment.name)
.then(resolve)
@@ -902,6 +1030,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
+ const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
@@ -919,12 +1048,17 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
}
const collectionCopy = cloneDeep(collection);
- if (environmentUid) {
- const environment = findEnvironmentInCollection(collectionCopy, environmentUid);
- if (!environment) {
- return reject(new Error('Environment not found'));
- }
- }
+
+ const environmentName = environmentUid
+ ? findEnvironmentInCollection(collectionCopy, environmentUid)?.name
+ : null;
+
+ if (environmentUid && !environmentName) {
+ return reject(new Error('Environment not found'));
+ }
+
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
resolve();
@@ -966,24 +1100,29 @@ export const browseDirectory = () => (dispatch, getState) => {
};
export const browseFiles =
- (filters = []) =>
- (dispatch, getState) => {
+ (filters, properties) =>
+ (_dispatch, _getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
- ipcRenderer.invoke('renderer:browse-files', filters).then(resolve).catch(reject);
+ ipcRenderer
+ .invoke('renderer:browse-files', filters, properties)
+ .then(resolve)
+ .catch(reject);
});
- };
+};
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
- if (!collection) {
- return reject(new Error('Collection not found'));
- }
return new Promise((resolve, reject) => {
+ if (!collection) {
+ return reject(new Error('Collection not found'));
+ }
+
+ const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
.then(resolve)
@@ -998,16 +1137,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
name: brunoConfig.name,
pathname: pathname,
items: [],
- collectionVariables: {},
+ runtimeVariables: {},
brunoConfig: brunoConfig
};
+ const { ipcRenderer } = window;
+
return new Promise((resolve, reject) => {
- collectionSchema
- .validate(collection)
- .then(() => dispatch(_createCollection(collection)))
- .then(resolve)
- .catch(reject);
+ ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
+ collectionSchema
+ .validate(collection)
+ .then(() => dispatch(_createCollection({ ...collection, securityConfig })))
+ .then(resolve)
+ .catch(reject);
+ });
});
};
@@ -1021,7 +1164,7 @@ export const createCollection = (collectionName, collectionFolderName, collectio
.catch(reject);
});
};
-export const cloneCollection = (collectionName, collectionFolderName, collectionLocation, perviousPath) => () => {
+export const cloneCollection = (collectionName, collectionFolderName, collectionLocation, previousPath) => () => {
const { ipcRenderer } = window;
return ipcRenderer.invoke(
@@ -1029,7 +1172,7 @@ export const cloneCollection = (collectionName, collectionFolderName, collection
collectionName,
collectionFolderName,
collectionLocation,
- perviousPath
+ previousPath
);
};
export const openCollection = () => () => {
@@ -1072,3 +1215,171 @@ export const importCollection = (collection, collectionLocation) => (dispatch, g
ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation).then(resolve).catch(reject);
});
};
+
+export const moveCollectionAndPersist = ({ draggedItem, targetItem }) => (dispatch, getState) => {
+ dispatch(moveCollection({ draggedItem, targetItem }));
+
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ const state = getState();
+
+ const collectionPaths = state.collections.collections.map((collection) => collection.pathname);
+
+ ipcRenderer
+ .invoke('renderer:update-collection-paths', collectionPaths)
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ const state = getState();
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+
+ ipcRenderer
+ .invoke('renderer:save-collection-security-config', collection?.pathname, securityConfig)
+ .then(async () => {
+ await dispatch(setCollectionSecurityConfig({ collectionUid, securityConfig }));
+ resolve();
+ })
+ .catch(reject);
+ });
+};
+
+
+export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getState) => {
+ const collectionSnapshotData = payload;
+ return new Promise((resolve, reject) => {
+ const state = getState();
+ try {
+ if(!collectionSnapshotData) resolve();
+ const { pathname, selectedEnvironment } = collectionSnapshotData;
+ const collection = findCollectionByPathname(state.collections.collections, pathname);
+ const collectionCopy = cloneDeep(collection);
+ const collectionUid = collectionCopy?.uid;
+
+ // update selected environment
+ if (selectedEnvironment) {
+ const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
+ if (environment) {
+ dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
+ }
+ }
+
+ // todo: add any other redux state that you want to save
+
+ resolve();
+ }
+ catch(error) {
+ reject(error);
+ }
+ });
+};
+
+export const fetchOauth2Credentials = (payload) => async (dispatch, getState) => {
+ const { request, collection, itemUid, folderUid } = payload;
+ const state = getState();
+ const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ request.globalEnvironmentVariables = globalEnvironmentVariables;
+ return new Promise((resolve, reject) => {
+ window.ipcRenderer
+ .invoke('renderer:fetch-oauth2-credentials', { itemUid, request, collection })
+ .then(({ credentials, url, collectionUid, credentialsId, debugInfo }) => {
+ dispatch(
+ collectionAddOauth2CredentialsByUrl({
+ credentials,
+ url,
+ collectionUid,
+ credentialsId,
+ debugInfo: safeParseJSON(safeStringifyJSON(debugInfo)),
+ folderUid: folderUid || null,
+ itemUid: !folderUid ? itemUid : null
+ })
+ );
+ resolve(credentials);
+ })
+ .catch(reject);
+ });
+};
+
+export const refreshOauth2Credentials = (payload) => async (dispatch, getState) => {
+ const { request, collection, folderUid, itemUid } = payload;
+ const state = getState();
+ const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ request.globalEnvironmentVariables = globalEnvironmentVariables;
+ return new Promise((resolve, reject) => {
+ window.ipcRenderer
+ .invoke('renderer:refresh-oauth2-credentials', { request, collection })
+ .then(({ credentials, url, collectionUid, debugInfo, credentialsId }) => {
+ dispatch(
+ collectionAddOauth2CredentialsByUrl({
+ credentials,
+ url,
+ collectionUid,
+ credentialsId,
+ debugInfo: safeParseJSON(safeStringifyJSON(debugInfo)),
+ folderUid: folderUid || null,
+ itemUid: !folderUid ? itemUid : null
+ })
+ );
+ resolve(credentials);
+ })
+ .catch(reject);
+ });
+};
+
+export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
+ const { collectionUid, url, credentialsId } = payload;
+ return new Promise((resolve, reject) => {
+ window.ipcRenderer
+ .invoke('clear-oauth2-cache', collectionUid, url, credentialsId)
+ .then(() => {
+ dispatch(
+ collectionClearOauth2CredentialsByUrl({
+ url,
+ collectionUid,
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
+ });
+};
+
+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 15afa72f5..64a72a43a 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -1,17 +1,10 @@
+import { uuid } from 'utils/common';
+import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
-import cloneDeep from 'lodash/cloneDeep';
-import concat from 'lodash/concat';
-import each from 'lodash/each';
-import filter from 'lodash/filter';
-import find from 'lodash/find';
-import forOwn from 'lodash/forOwn';
-import get from 'lodash/get';
-import map from 'lodash/map';
-import set from 'lodash/set';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
- collapseCollection,
+ collapseAllItemsInCollection,
deleteItemInCollection,
deleteItemInCollectionByPathname,
findCollectionByPathname,
@@ -19,11 +12,14 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findItemInCollectionByPathname,
+ isItemAFolder,
isItemARequest
} from 'utils/collections';
-import { uuid } from 'utils/common';
-import { PATH_SEPARATOR, getDirectoryName, getSubdirectoriesFromRoot } from 'utils/common/platform';
-import { parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
+import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
+import { getSubdirectoriesFromRoot } from 'utils/common/platform';
+import toast from 'react-hot-toast';
+import mime from 'mime-types';
+import path from 'utils/common/path';
const initialState = {
collections: [],
@@ -38,7 +34,12 @@ 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
@@ -49,12 +50,26 @@ 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) {
+ if (action.payload.mountStatus) {
+ collection.mountStatus = action.payload.mountStatus;
+ }
+ }
+ },
+ setCollectionSecurityConfig: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ if (collection) {
+ collection.securityConfig = action.payload.securityConfig;
+ }
+ },
brunoConfigUpdateEvent: (state, action) => {
const { collectionUid, brunoConfig } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -75,18 +90,25 @@ export const collectionsSlice = createSlice({
},
sortCollections: (state, action) => {
state.collectionSortOrder = action.payload.order;
+ const collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
switch (action.payload.order) {
case 'default':
state.collections = state.collections.sort((a, b) => a.importedAt - b.importedAt);
break;
case 'alphabetical':
- state.collections = state.collections.sort((a, b) => a.name.localeCompare(b.name));
+ state.collections = state.collections.sort((a, b) => collator.compare(a.name, b.name));
break;
case 'reverseAlphabetical':
- state.collections = state.collections.sort((a, b) => b.name.localeCompare(a.name));
+ state.collections = state.collections.sort((a, b) => -collator.compare(a.name, b.name));
break;
}
},
+ moveCollection: (state, action) => {
+ const { draggedItem, targetItem } = action.payload;
+ state.collections = state.collections.filter((i) => i.uid !== draggedItem.uid); // Remove dragged item
+ const targetItemIndex = state.collections.findIndex((i) => i.uid === targetItem.uid); // Find target item
+ state.collections.splice(targetItemIndex, 0, draggedItem); // Insert dragged-item above target-item
+ },
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -96,7 +118,7 @@ export const collectionsSlice = createSlice({
}
},
updateSettingsSelectedTab: (state, action) => {
- const { collectionUid, tab } = action.payload;
+ const { collectionUid, folderUid, tab } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -104,6 +126,19 @@ export const collectionsSlice = createSlice({
collection.settingsSelectedTab = tab;
}
},
+ updatedFolderSettingsSelectedTab: (state, action) => {
+ const { collectionUid, folderUid, tab } = action.payload;
+
+ const collection = findCollectionByUid(state.collections, collectionUid);
+
+ if (collection) {
+ const folder = findItemInCollection(collection, folderUid);
+
+ if (folder) {
+ collection.folderLevelSettingsSelectedTab[folderUid] = tab;
+ }
+ }
+ },
collectionUnlinkEnvFileEvent: (state, action) => {
const { data: environment, meta } = action.payload;
const collection = findCollectionByUid(state.collections, meta.collectionUid);
@@ -191,7 +226,7 @@ export const collectionsSlice = createSlice({
}
},
scriptEnvironmentUpdateEvent: (state, action) => {
- const { collectionUid, envVariables, collectionVariables } = action.payload;
+ const { collectionUid, envVariables, runtimeVariables } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
@@ -221,7 +256,7 @@ export const collectionsSlice = createSlice({
});
}
- collection.collectionVariables = collectionVariables;
+ collection.runtimeVariables = runtimeVariables;
}
},
processEnvUpdateEvent: (state, action) => {
@@ -241,18 +276,44 @@ export const collectionsSlice = createSlice({
if (item) {
item.response = null;
item.cancelTokenUid = null;
+ item.requestUid = null;
+ item.requestStartTime = null;
}
}
},
responseReceived: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
-
+
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
+ item.requestStartTime = null;
+
+ if (!collection.timeline) {
+ collection.timeline = [];
+ }
+
+ // Ensure timestamp is a number (milliseconds since epoch)
+ const timestamp = item?.requestSent?.timestamp instanceof Date
+ ? item.requestSent.timestamp.getTime()
+ : item?.requestSent?.timestamp || Date.now();
+
+ // Append the new timeline entry with numeric timestamp
+ collection.timeline.push({
+ type: "request",
+ collectionUid: collection.uid,
+ folderUid: null,
+ itemUid: item.uid,
+ timestamp: timestamp,
+ data: {
+ request: item.requestSent || item.request,
+ response: action.payload.response,
+ timestamp: timestamp,
+ }
+ });
}
}
},
@@ -266,6 +327,23 @@ export const collectionsSlice = createSlice({
}
}
},
+ clearTimeline: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ collection.timeline = [];
+ }
+ },
+ clearRequestTimeline: (state, action) => {
+ const { collectionUid, itemUid } = action.payload || {};
+ const collection = findCollectionByUid(state.collections, collectionUid);
+
+ if (collection) {
+ if (itemUid) {
+ collection.timeline = collection?.timeline?.filter(t => t?.itemUid !== itemUid);
+ }
+ }
+ },
saveRequest: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -294,10 +372,35 @@ export const collectionsSlice = createSlice({
if (collection && collection.items && collection.items.length) {
const parts = splitOnFirst(action.payload.requestUrl, '?');
- const params = parseQueryParams(parts[1]);
- each(params, (urlParam) => {
- urlParam.enabled = true;
- });
+ const queryParams = parseQueryParams(parts[1]);
+
+ let pathParams = [];
+ try {
+ pathParams = parsePathParams(parts[0]);
+ } catch (err) {
+ console.error(err);
+ toast.error(err.message);
+ }
+
+ const queryParamObjects = queryParams.map((param) => ({
+ uid: uuid(),
+ name: param.key,
+ value: param.value,
+ description: '',
+ type: 'query',
+ enabled: true
+ }));
+
+ const pathParamObjects = pathParams.map((param) => ({
+ uid: uuid(),
+ name: param.key,
+ value: param.value,
+ description: '',
+ type: 'path',
+ enabled: true
+ }));
+
+ const params = [...queryParamObjects, ...pathParamObjects];
const item = {
uid: action.payload.uid,
@@ -319,7 +422,7 @@ export const collectionsSlice = createSlice({
collection.items.push(item);
}
},
- collectionClicked: (state, action) => {
+ collapseCollection: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload);
if (collection) {
@@ -349,27 +452,59 @@ export const collectionsSlice = createSlice({
}
item.draft.request.url = action.payload.url;
- const parts = splitOnFirst(item.draft.request.url, '?');
- const urlParams = parseQueryParams(parts[1]);
- const disabledParams = filter(item.draft.request.params, (p) => !p.enabled);
- let enabledParams = filter(item.draft.request.params, (p) => p.enabled);
+ const parts = splitOnFirst(item?.draft?.request?.url, '?');
+ const urlQueryParams = parseQueryParams(parts[1]);
+ let urlPathParams = [];
+
+ try {
+ urlPathParams = parsePathParams(parts[0]);
+ } catch (err) {
+ console.error(err);
+ toast.error(err.message);
+ }
+
+ const disabledQueryParams = filter(item?.draft?.request?.params, (p) => !p.enabled && p.type === 'query');
+ let enabledQueryParams = filter(item?.draft?.request?.params, (p) => p.enabled && p.type === 'query');
+ let oldPathParams = filter(item?.draft?.request?.params, (p) => p.enabled && p.type === 'path');
+ let newPathParams = [];
// try and connect as much as old params uid's as possible
- each(urlParams, (urlParam) => {
- const existingParam = find(enabledParams, (p) => p.name === urlParam.name || p.value === urlParam.value);
- urlParam.uid = existingParam ? existingParam.uid : uuid();
- urlParam.enabled = true;
+ each(urlQueryParams, (urlQueryParam) => {
+ const existingQueryParam = find(
+ enabledQueryParams,
+ (p) => p?.name === urlQueryParam?.name || p?.value === urlQueryParam?.value
+ );
+ urlQueryParam.uid = existingQueryParam?.uid || uuid();
+ urlQueryParam.enabled = true;
+ urlQueryParam.type = 'query';
// once found, remove it - trying our best here to accommodate duplicate query params
- if (existingParam) {
- enabledParams = filter(enabledParams, (p) => p.uid !== existingParam.uid);
+ if (existingQueryParam) {
+ enabledQueryParams = filter(enabledQueryParams, (p) => p?.uid !== existingQueryParam?.uid);
}
});
+ // filter the newest path param and compare with previous data that already inserted
+ newPathParams = filter(urlPathParams, (urlPath) => {
+ const existingPathParam = find(oldPathParams, (p) => p.name === urlPath.name);
+ if (existingPathParam) {
+ return false;
+ }
+ urlPath.uid = uuid();
+ urlPath.enabled = true;
+ urlPath.type = 'path';
+ return true;
+ });
+
+ // remove path param that not used or deleted when typing url
+ oldPathParams = filter(oldPathParams, (urlPath) => {
+ return find(urlPathParams, (p) => p.name === urlPath.name);
+ });
+
// ultimately params get replaced with params in url + the disabled ones that existed prior
// the query params are the source of truth, the url in the queryurl input gets constructed using these params
// we however are also storing the full url (with params) in the url itself
- item.draft.request.params = concat(urlParams, disabledParams);
+ item.draft.request.params = concat(urlQueryParams, newPathParams, disabledQueryParams, oldPathParams);
}
}
},
@@ -402,10 +537,22 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'digest';
item.draft.request.auth.digest = action.payload.content;
break;
+ case 'ntlm':
+ item.draft.request.auth.mode = 'ntlm';
+ item.draft.request.auth.ntlm = action.payload.content;
+ break;
case 'oauth2':
item.draft.request.auth.mode = 'oauth2';
item.draft.request.auth.oauth2 = action.payload.content;
break;
+ case 'wsse':
+ item.draft.request.auth.mode = 'wsse';
+ item.draft.request.auth.wsse = action.payload.content;
+ break;
+ case 'apikey':
+ item.draft.request.auth.mode = 'apikey';
+ item.draft.request.auth.apikey = action.payload.content;
+ break;
}
}
}
@@ -426,11 +573,50 @@ export const collectionsSlice = createSlice({
name: '',
value: '',
description: '',
+ type: 'query',
enabled: true
});
}
}
},
+
+ moveQueryParam: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ const params = item.draft.request.params;
+
+ const queryParams = params.filter((param) => param.type === 'query');
+ const pathParams = params.filter((param) => param.type === 'path');
+
+ // Reorder only query params based on updateReorderedItem
+ const reorderedQueryParams = updateReorderedItem.map((uid) => {
+ return queryParams.find((param) => param.uid === uid);
+ });
+ item.draft.request.params = [...reorderedQueryParams, ...pathParams];
+
+ // Update request URL
+ const parts = splitOnFirst(item.draft.request.url, '?');
+ const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query'));
+ if (query && query.length) {
+ item.draft.request.url = parts[0] + '?' + query;
+ } else {
+ item.draft.request.url = parts[0];
+ }
+ }
+ }
+ },
+
updateQueryParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -441,16 +627,20 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
- const param = find(item.draft.request.params, (h) => h.uid === action.payload.param.uid);
- if (param) {
- param.name = action.payload.param.name;
- param.value = action.payload.param.value;
- param.description = action.payload.param.description;
- param.enabled = action.payload.param.enabled;
+ const queryParam = find(
+ item.draft.request.params,
+ (h) => h.uid === action.payload.queryParam.uid && h.type === 'query'
+ );
+ if (queryParam) {
+ queryParam.name = action.payload.queryParam.name;
+ queryParam.value = action.payload.queryParam.value;
+ queryParam.enabled = action.payload.queryParam.enabled;
// update request url
const parts = splitOnFirst(item.draft.request.url, '?');
- const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled));
+ const query = stringifyQueryParams(
+ filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')
+ );
// if no query is found, then strip the query params in url
if (!query || !query.length) {
@@ -486,7 +676,7 @@ export const collectionsSlice = createSlice({
// update request url
const parts = splitOnFirst(item.draft.request.url, '?');
- const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled));
+ const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query'));
if (query && query.length) {
item.draft.request.url = parts[0] + '?' + query;
} else {
@@ -495,6 +685,29 @@ export const collectionsSlice = createSlice({
}
}
},
+ updatePathParam: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ const param = find(
+ item.draft.request.params,
+ (p) => p.uid === action.payload.pathParam.uid && p.type === 'path'
+ );
+
+ if (param) {
+ param.name = action.payload.pathParam.name;
+ param.value = action.payload.pathParam.value;
+ }
+ }
+ }
+ },
addRequestHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -550,6 +763,28 @@ export const collectionsSlice = createSlice({
}
}
},
+ moveRequestHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ const params = item.draft.request.headers;
+
+ item.draft.request.headers = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ }
+ }
+ },
addFormUrlEncodedParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -608,6 +843,28 @@ export const collectionsSlice = createSlice({
}
}
},
+ moveFormUrlEncodedParam: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ const params = item.draft.request.body.formUrlEncoded;
+
+ item.draft.request.body.formUrlEncoded = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ }
+ }
+ },
addMultipartFormParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -623,8 +880,9 @@ export const collectionsSlice = createSlice({
uid: uuid(),
type: action.payload.type,
name: '',
- value: '',
+ value: action.payload.value,
description: '',
+ contentType: '',
enabled: true
});
}
@@ -646,6 +904,7 @@ export const collectionsSlice = createSlice({
param.name = action.payload.param.name;
param.value = action.payload.param.value;
param.description = action.payload.param.description;
+ param.contentType = action.payload.param.contentType;
param.enabled = action.payload.param.enabled;
}
}
@@ -668,6 +927,98 @@ export const collectionsSlice = createSlice({
}
}
},
+ moveMultipartFormParam: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ const params = item.draft.request.body.multipartForm;
+
+ item.draft.request.body.multipartForm = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ }
+ }
+ },
+ addFile: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+ item.draft.request.body.file = item.draft.request.body.file || [];
+
+ item.draft.request.body.file.push({
+ uid: uuid(),
+ filePath: '',
+ contentType: '',
+ selected: false
+ });
+ }
+ }
+ },
+ updateFile: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ const param = find(item.draft.request.body.file, (p) => p.uid === action.payload.param.uid);
+
+ if (param) {
+ const contentType = mime.contentType(path.extname(action.payload.param.filePath));
+ param.filePath = action.payload.param.filePath;
+ param.contentType = action.payload.param.contentType || contentType || '';
+ param.selected = action.payload.param.selected;
+
+ item.draft.request.body.file = item.draft.request.body.file.map((p) => {
+ p.selected = p.uid === param.uid;
+ return p;
+ });
+ }
+ }
+ }
+ },
+ deleteFile: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ item.draft.request.body.file = filter(
+ item.draft.request.body.file,
+ (p) => p.uid !== action.payload.paramUid
+ );
+
+ if (item.draft.request.body.file.length > 0) {
+ item.draft.request.body.file[0].selected = true;
+ }
+ }
+ }
+ },
updateRequestAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -724,6 +1075,10 @@ export const collectionsSlice = createSlice({
item.draft.request.body.sparql = action.payload.content;
break;
}
+ case 'file': {
+ item.draft.request.body.file = action.payload.content;
+ break;
+ }
case 'formUrlEncoded': {
item.draft.request.body.formUrlEncoded = action.payload.content;
break;
@@ -881,6 +1236,28 @@ export const collectionsSlice = createSlice({
}
}
},
+ moveAssertion: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ const params = item.draft.request.assertions;
+
+ item.draft.request.assertions = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ }
+ }
+ },
addVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
@@ -975,6 +1352,37 @@ export const collectionsSlice = createSlice({
}
}
},
+ moveVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const type = action.payload.type;
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ // Ensure item.draft is a deep clone of item if not already present
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+
+ // Extract payload data
+ const { updateReorderedItem } = action.payload;
+ if(type == "request"){
+ const params = item.draft.request.vars.req;
+
+ item.draft.request.vars.req = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ } else if (type === 'response') {
+ const params = item.draft.request.vars.res;
+
+ item.draft.request.vars.res = updateReorderedItem.map((uid) => {
+ return params.find((param) => param.uid === uid);
+ });
+ }
+ }
+ }
+ },
updateCollectionAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1002,9 +1410,18 @@ export const collectionsSlice = createSlice({
case 'digest':
set(collection, 'root.request.auth.digest', action.payload.content);
break;
+ case 'ntlm':
+ set(collection, 'root.request.auth.ntlm', action.payload.content);
+ break;
case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content);
break;
+ case 'wsse':
+ set(collection, 'root.request.auth.wsse', action.payload.content);
+ break;
+ case 'apikey':
+ set(collection, 'root.request.auth.apikey', action.payload.content);
+ break;
}
}
},
@@ -1036,6 +1453,173 @@ export const collectionsSlice = createSlice({
set(collection, 'root.docs', action.payload.docs);
}
},
+ addFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ const headers = get(folder, 'root.request.headers', []);
+ headers.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ description: '',
+ enabled: true
+ });
+ set(folder, 'root.request.headers', headers);
+ }
+ },
+ updateFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ const headers = get(folder, 'root.request.headers', []);
+ const header = find(headers, (h) => h.uid === action.payload.header.uid);
+ if (header) {
+ header.name = action.payload.header.name;
+ header.value = action.payload.header.value;
+ header.description = action.payload.header.description;
+ header.enabled = action.payload.header.enabled;
+ }
+ }
+ },
+ deleteFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ let headers = get(folder, 'root.request.headers', []);
+ headers = filter(headers, (h) => h.uid !== action.payload.headerUid);
+ set(folder, 'root.request.headers', headers);
+ }
+ },
+ addFolderVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ const type = action.payload.type;
+ if (folder) {
+ if (type === 'request') {
+ const vars = get(folder, 'root.request.vars.req', []);
+ vars.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ enabled: true
+ });
+ set(folder, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ const vars = get(folder, 'root.request.vars.res', []);
+ vars.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ enabled: true
+ });
+ set(folder, 'root.request.vars.res', vars);
+ }
+ }
+ },
+ updateFolderVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ const type = action.payload.type;
+ if (folder) {
+ if (type === 'request') {
+ let vars = get(folder, 'root.request.vars.req', []);
+ const _var = find(vars, (h) => h.uid === action.payload.var.uid);
+ if (_var) {
+ _var.name = action.payload.var.name;
+ _var.value = action.payload.var.value;
+ _var.description = action.payload.var.description;
+ _var.enabled = action.payload.var.enabled;
+ }
+ set(folder, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ let vars = get(folder, 'root.request.vars.res', []);
+ const _var = find(vars, (h) => h.uid === action.payload.var.uid);
+ if (_var) {
+ _var.name = action.payload.var.name;
+ _var.value = action.payload.var.value;
+ _var.description = action.payload.var.description;
+ _var.enabled = action.payload.var.enabled;
+ }
+ set(folder, 'root.request.vars.res', vars);
+ }
+ }
+ },
+ deleteFolderVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ const type = action.payload.type;
+ if (folder) {
+ if (type === 'request') {
+ let vars = get(folder, 'root.request.vars.req', []);
+ vars = filter(vars, (h) => h.uid !== action.payload.varUid);
+ set(folder, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ let vars = get(folder, 'root.request.vars.res', []);
+ vars = filter(vars, (h) => h.uid !== action.payload.varUid);
+ set(folder, 'root.request.vars.res', vars);
+ }
+ }
+ },
+ updateFolderRequestScript: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ set(folder, 'root.request.script.req', action.payload.script);
+ }
+ },
+ updateFolderResponseScript: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ set(folder, 'root.request.script.res', action.payload.script);
+ }
+ },
+ updateFolderTests: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ set(folder, 'root.request.tests', action.payload.tests);
+ }
+ },
+ updateFolderAuth: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ if (!collection) return;
+
+ const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null;
+ if (!folder) return;
+
+ if (folder) {
+ set(folder, 'root.request.auth', {});
+ set(folder, 'root.request.auth.mode', action.payload.mode);
+ switch (action.payload.mode) {
+ case 'oauth2':
+ set(folder, 'root.request.auth.oauth2', action.payload.content);
+ break;
+ case 'basic':
+ set(folder, 'root.request.auth.basic', action.payload.content);
+ break;
+ case 'bearer':
+ set(folder, 'root.request.auth.bearer', action.payload.content);
+ break;
+ case 'digest':
+ set(folder, 'root.request.auth.digest', action.payload.content);
+ break;
+ case 'ntlm':
+ set(folder, 'root.request.auth.ntlm', action.payload.content);
+ break;
+ case 'apikey':
+ set(folder, 'root.request.auth.apikey', action.payload.content);
+ break;
+ case 'awsv4':
+ set(folder, 'root.request.auth.awsv4', action.payload.content);
+ break;
+ case 'wsse':
+ set(folder, 'root.request.auth.wsse', action.payload.content);
+ break;
+ }
+ }
+ },
addCollectionHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1074,11 +1658,76 @@ export const collectionsSlice = createSlice({
set(collection, 'root.request.headers', headers);
}
},
+ addCollectionVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const type = action.payload.type;
+ if (collection) {
+ if (type === 'request') {
+ const vars = get(collection, 'root.request.vars.req', []);
+ vars.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ enabled: true
+ });
+ set(collection, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ const vars = get(collection, 'root.request.vars.res', []);
+ vars.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ enabled: true
+ });
+ set(collection, 'root.request.vars.res', vars);
+ }
+ }
+ },
+ updateCollectionVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const type = action.payload.type;
+ if (type === 'request') {
+ let vars = get(collection, 'root.request.vars.req', []);
+ const _var = find(vars, (h) => h.uid === action.payload.var.uid);
+ if (_var) {
+ _var.name = action.payload.var.name;
+ _var.value = action.payload.var.value;
+ _var.description = action.payload.var.description;
+ _var.enabled = action.payload.var.enabled;
+ }
+ set(collection, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ let vars = get(collection, 'root.request.vars.res', []);
+ const _var = find(vars, (h) => h.uid === action.payload.var.uid);
+ if (_var) {
+ _var.name = action.payload.var.name;
+ _var.value = action.payload.var.value;
+ _var.description = action.payload.var.description;
+ _var.enabled = action.payload.var.enabled;
+ }
+ set(collection, 'root.request.vars.res', vars);
+ }
+ },
+ deleteCollectionVar: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const type = action.payload.type;
+ if (collection) {
+ if (type === 'request') {
+ let vars = get(collection, 'root.request.vars.req', []);
+ vars = filter(vars, (h) => h.uid !== action.payload.varUid);
+ set(collection, 'root.request.vars.req', vars);
+ } else if (type === 'response') {
+ let vars = get(collection, 'root.request.vars.res', []);
+ vars = filter(vars, (h) => h.uid !== action.payload.varUid);
+ set(collection, 'root.request.vars.res', vars);
+ }
+ }
+ },
collectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
+ const isFolderRoot = file.meta.folderRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
-
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
@@ -1086,30 +1735,44 @@ export const collectionsSlice = createSlice({
return;
}
+ if (isFolderRoot) {
+ const folderPath = path.dirname(file.meta.pathname);
+ const folderItem = findItemInCollectionByPathname(collection, folderPath);
+ if (folderItem) {
+ if (file?.data?.meta?.name) {
+ folderItem.name = file?.data?.meta?.name;
+ }
+ folderItem.root = file.data;
+ if (file?.data?.meta?.seq) {
+ folderItem.seq = file.data?.meta?.seq;
+ }
+ }
+ return;
+ }
+
if (collection) {
- const dirname = getDirectoryName(file.meta.pathname);
+ const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
- let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName);
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
- pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
+ pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
- items: []
+ items: [],
};
currentSubItems.push(childItem);
}
-
- currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
- if (!currentSubItems.find((f) => f.name === file.meta.name)) {
+ if (file.meta.name != 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
// this happens when you rename a file
// the add event might get triggered first, before the unlink event
// this results in duplicate uids causing react renderer to go mad
@@ -1122,6 +1785,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,
@@ -1131,7 +1798,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
});
}
}
@@ -1147,20 +1818,21 @@ export const collectionsSlice = createSlice({
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
- let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName);
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
- uid: uuid(),
- pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
- name: directoryName,
+ uid: dir?.meta?.uid || uuid(),
+ pathname: currentPath,
+ name: dir?.meta?.name || directoryName,
+ seq: dir?.meta?.seq || 1,
+ filename: directoryName,
collapsed: true,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
-
- currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
addDepth(collection.items);
@@ -1168,11 +1840,28 @@ export const collectionsSlice = createSlice({
},
collectionChangeFileEvent: (state, action) => {
const { file } = action.payload;
+ const isCollectionRoot = file.meta.collectionRoot ? true : false;
+ const isFolderRoot = file.meta.folderRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
+ if (isCollectionRoot) {
+ if (collection) {
+ collection.root = file.data;
+ }
+ return;
+ }
- // check and update collection root
- if (collection && file.meta.collectionRoot) {
- collection.root = file.data;
+ if (isFolderRoot) {
+ const folderPath = path.dirname(file.meta.pathname);
+ const folderItem = findItemInCollectionByPathname(collection, folderPath);
+ if (folderItem) {
+ if (file?.data?.meta?.name) {
+ folderItem.name = file?.data?.meta?.name;
+ }
+ if (file?.data?.meta?.seq) {
+ folderItem.seq = file?.data?.meta?.seq;
+ }
+ folderItem.root = file.data;
+ }
return;
}
@@ -1185,6 +1874,12 @@ export const collectionsSlice = createSlice({
// we don't want to lose the draft in this case
if (areItemsTheSameExceptSeqUpdate(item, file.data)) {
item.seq = file.data.seq;
+ if (item?.draft) {
+ item.draft.seq = file.data.seq;
+ }
+ if (item?.draft && areItemsTheSameExceptSeqUpdate(item?.draft, file.data)) {
+ item.draft = null;
+ }
} else {
item.name = file.data.name;
item.type = file.data.type;
@@ -1262,6 +1957,18 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
}
},
+ initRunRequestEvent: (state, action) => {
+ const { requestUid, itemUid, collectionUid } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+
+ const item = findItemInCollection(collection, itemUid);
+ if (!item) return;
+
+ item.requestState = null;
+ item.requestUid = requestUid;
+ item.requestStartTime = Date.now();
+ },
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -1269,9 +1976,21 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
+ // ignore outdated updates in case multiple requests are fired rapidly to avoid state inconsistency
+ if (item.requestUid !== requestUid) return;
+
+ if (type === 'pre-request-script-execution') {
+ item.preRequestScriptErrorMessage = action.payload.errorMessage;
+ }
+
+ if(type === 'post-response-script-execution') {
+ item.postResponseScriptErrorMessage = action.payload.errorMessage;
+ }
+
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
- item.requestUid = requestUid;
+ // ignore if request is already in progress or completed
+ if (['sending', 'received'].includes(item.requestState)) return;
item.requestState = 'queued';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1279,10 +1998,9 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
-
+
// sometimes the response is received before the request-sent event arrives
- if (item.requestUid === requestUid && item.requestState === 'queued') {
- item.requestUid = requestUid;
+ if (item.requestState === 'queued') {
item.requestState = 'sending';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1297,6 +2015,16 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
+
+ if (type === 'test-results-pre-request') {
+ const { results } = action.payload;
+ item.preRequestTestResults = results;
+ }
+
+ if (type === 'test-results-post-response') {
+ const { results } = action.payload;
+ item.postResponseTestResults = results;
+ }
}
}
},
@@ -1324,6 +2052,9 @@ export const collectionsSlice = createSlice({
if (type === 'testrun-ended') {
const info = collection.runnerResult.info;
info.status = 'ended';
+ if (action.payload.statusText) {
+ info.statusText = action.payload.statusText;
+ }
}
if (type === 'request-queued') {
@@ -1334,33 +2065,49 @@ export const collectionsSlice = createSlice({
}
if (type === 'request-sent') {
- const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.status = 'running';
item.requestSent = action.payload.requestSent;
}
if (type === 'response-received') {
- const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.status = 'completed';
item.responseReceived = action.payload.responseReceived;
}
if (type === 'test-results') {
- const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.testResults = action.payload.testResults;
}
+ if (type === 'test-results-pre-request') {
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ item.preRequestTestResults = action.payload.preRequestTestResults;
+ }
+
+ if (type === 'test-results-post-response') {
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ item.postResponseTestResults = action.payload.postResponseTestResults;
+ }
+
if (type === 'assertion-results') {
- const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.assertionResults = action.payload.assertionResults;
}
if (type === 'error') {
- const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.error = action.payload.error;
item.responseReceived = action.payload.responseReceived;
item.status = 'error';
}
+
+ if (type === 'runner-request-skipped') {
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ item.status = 'skipped';
+ item.responseReceived = action.payload.responseReceived;
+ }
}
},
resetCollectionRunner: (state, action) => {
@@ -1384,18 +2131,122 @@ export const collectionsSlice = createSlice({
item.draft.request.docs = action.payload.docs;
}
}
+ },
+ updateFolderDocs: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ if (isItemAFolder(folder)) {
+ set(folder, 'root.docs', action.payload.docs);
+ }
+ }
+ },
+ collectionAddOauth2CredentialsByUrl: (state, action) => {
+ const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+
+ // Update oauth2Credentials (latest token)
+ if (!collection.oauth2Credentials) {
+ collection.oauth2Credentials = [];
+ }
+ let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
+
+ // Remove existing credentials for the same combination
+ const filteredOauth2Credentials = filter(
+ collectionOauth2Credentials,
+ (creds) =>
+ !(creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId)
+ );
+
+ // Add the new credential with folderUid and itemUid
+ filteredOauth2Credentials.push({
+ collectionUid,
+ folderUid,
+ itemUid,
+ url,
+ credentials,
+ credentialsId,
+ debugInfo
+ });
+
+ collection.oauth2Credentials = filteredOauth2Credentials;
+
+ if (!collection.timeline) {
+ collection.timeline = [];
+ }
+
+ if(debugInfo) {
+ collection.timeline.push({
+ type: "oauth2",
+ collectionUid,
+ folderUid,
+ itemUid,
+ timestamp: Date.now(),
+ data: {
+ collectionUid,
+ folderUid,
+ itemUid,
+ url,
+ credentials,
+ credentialsId,
+ debugInfo: debugInfo.data,
+ }
+ });
+ }
+ },
+
+ collectionClearOauth2CredentialsByUrl: (state, action) => {
+ const { collectionUid, url, credentialsId } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+
+ if (collection.oauth2Credentials) {
+ let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
+ const filteredOauth2Credentials = filter(
+ collectionOauth2Credentials,
+ (creds) =>
+ !(creds.url === url && creds.collectionUid === collectionUid)
+ );
+ collection.oauth2Credentials = filteredOauth2Credentials;
+ }
+ },
+
+ collectionGetOauth2CredentialsByUrl: (state, action) => {
+ const { collectionUid, url, credentialsId } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ const oauth2Credential = find(
+ collection?.oauth2Credentials || [],
+ (creds) =>
+ creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId
+ );
+ return oauth2Credential;
+ },
+
+ updateFolderAuthMode: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+
+ if (folder) {
+ set(folder, 'root.request.auth', {});
+ set(folder, 'root.request.auth.mode', action.payload.mode);
+ }
}
- }
+ },
+
});
export const {
createCollection,
+ updateCollectionMountStatus,
+ setCollectionSecurityConfig,
brunoConfigUpdateEvent,
renameCollection,
removeCollection,
sortCollections,
updateLastAction,
updateSettingsSelectedTab,
+ updatedFolderSettingsSelectedTab,
collectionUnlinkEnvFileEvent,
saveEnvironment,
selectEnvironment,
@@ -1408,25 +2259,35 @@ export const {
requestCancelled,
responseReceived,
responseCleared,
+ clearTimeline,
+ clearRequestTimeline,
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
- collectionClicked,
+ collapseCollection,
collectionFolderClicked,
requestUrlChanged,
updateAuth,
addQueryParam,
+ moveQueryParam,
updateQueryParam,
deleteQueryParam,
+ updatePathParam,
addRequestHeader,
updateRequestHeader,
deleteRequestHeader,
+ moveRequestHeader,
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,
+ moveFormUrlEncodedParam,
addMultipartFormParam,
updateMultipartFormParam,
deleteMultipartFormParam,
+ addFile,
+ updateFile,
+ deleteFile,
+ moveMultipartFormParam,
updateRequestAuthMode,
updateRequestBodyMode,
updateRequestBody,
@@ -1439,12 +2300,26 @@ export const {
addAssertion,
updateAssertion,
deleteAssertion,
+ moveAssertion,
addVar,
updateVar,
deleteVar,
+ moveVar,
+ addFolderHeader,
+ updateFolderHeader,
+ deleteFolderHeader,
+ addFolderVar,
+ updateFolderVar,
+ deleteFolderVar,
+ updateFolderRequestScript,
+ updateFolderResponseScript,
+ updateFolderTests,
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
+ addCollectionVar,
+ updateCollectionVar,
+ deleteCollectionVar,
updateCollectionAuthMode,
updateCollectionAuth,
updateCollectionRequestScript,
@@ -1459,10 +2334,18 @@ export const {
collectionAddEnvFileEvent,
collectionRenamedEvent,
resetRunResults,
+ initRunRequestEvent,
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
- updateRequestDocs
+ updateRequestDocs,
+ updateFolderDocs,
+ moveCollection,
+ collectionAddOauth2CredentialsByUrl,
+ collectionClearOauth2CredentialsByUrl,
+ collectionGetOauth2CredentialsByUrl,
+ updateFolderAuth,
+ updateFolderAuthMode,
} = collectionsSlice.actions;
export default collectionsSlice.reducer;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js
new file mode 100644
index 000000000..b208f14fd
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js
@@ -0,0 +1,234 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { uuid } from 'utils/common/index';
+import { environmentSchema } from '@usebruno/schema';
+import { cloneDeep } from 'lodash';
+
+const initialState = {
+ globalEnvironments: [],
+ activeGlobalEnvironmentUid: null
+};
+
+export const globalEnvironmentsSlice = createSlice({
+ name: 'global-environments',
+ initialState,
+ reducers: {
+ updateGlobalEnvironments: (state, action) => {
+ state.globalEnvironments = action.payload?.globalEnvironments;
+ state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
+ },
+ _addGlobalEnvironment: (state, action) => {
+ const { name, uid, variables = [] } = action.payload;
+ if (name?.length) {
+ state.globalEnvironments.push({
+ uid,
+ name,
+ variables
+ });
+ }
+ },
+ _saveGlobalEnvironment: (state, action) => {
+ const { environmentUid: globalEnvironmentUid, variables } = action.payload;
+ if (globalEnvironmentUid) {
+ const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ if (environment) {
+ environment.variables = variables;
+ }
+ }
+ },
+ _renameGlobalEnvironment: (state, action) => {
+ const { environmentUid: globalEnvironmentUid, name } = action.payload;
+ if (globalEnvironmentUid) {
+ const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ if (environment) {
+ environment.name = name;
+ }
+ }
+ },
+ _copyGlobalEnvironment: (state, action) => {
+ const { name, uid, variables } = action.payload;
+ if (name?.length && uid) {
+ state.globalEnvironments.push({
+ uid,
+ name,
+ variables
+ });
+ }
+ },
+ _selectGlobalEnvironment: (state, action) => {
+ const { environmentUid: globalEnvironmentUid } = action.payload;
+ if (globalEnvironmentUid) {
+ const environment = state.globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ if (environment) {
+ state.activeGlobalEnvironmentUid = globalEnvironmentUid;
+ }
+ } else {
+ state.activeGlobalEnvironmentUid = null;
+ }
+ },
+ _deleteGlobalEnvironment: (state, action) => {
+ const { environmentUid: uid } = action.payload;
+ if (uid) {
+ state.globalEnvironments = state.globalEnvironments.filter(env => env?.uid !== uid);
+ if (uid === state.activeGlobalEnvironmentUid) {
+ state.activeGlobalEnvironmentUid = null;
+ }
+ }
+ }
+ }
+});
+
+export const {
+ updateGlobalEnvironments,
+ _addGlobalEnvironment,
+ _saveGlobalEnvironment,
+ _renameGlobalEnvironment,
+ _copyGlobalEnvironment,
+ _selectGlobalEnvironment,
+ _deleteGlobalEnvironment
+} = globalEnvironmentsSlice.actions;
+
+export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const uid = uuid();
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:create-global-environment', { name, uid, variables })
+ .then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const state = getState();
+ const globalEnvironments = state.globalEnvironments.globalEnvironments;
+ const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
+ const uid = uuid();
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
+ .then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ const state = getState();
+ const globalEnvironments = state.globalEnvironments.globalEnvironments;
+ const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
+ if (!environment) {
+ return reject(new Error('Environment not found'));
+ }
+ environmentSchema
+ .validate(environment)
+ .then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid }))
+ .then(() => dispatch(_renameGlobalEnvironment({ name: newName, environmentUid })))
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const state = getState();
+ const globalEnvironments = state.globalEnvironments.globalEnvironments;
+ const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
+
+ if (!environment) {
+ return reject(new Error('Environment not found'));
+ }
+
+ const { ipcRenderer } = window;
+ environmentSchema
+ .validate(environment)
+ .then(() => ipcRenderer.invoke('renderer:save-global-environment', {
+ environmentUid,
+ variables
+ }))
+ .then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
+ .then(resolve)
+ .catch((error) => {
+ reject(error);
+ });
+ });
+};
+
+export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:select-global-environment', { environmentUid })
+ .then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:delete-global-environment', { environmentUid })
+ .then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
+ .then(resolve)
+ .catch(reject);
+ });
+};
+
+export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ if (!globalEnvironmentVariables) resolve();
+
+ const state = getState();
+ const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
+ const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
+ const environment = globalEnvironments?.find(env => env?.uid == environmentUid);
+
+ if (!environment || !environmentUid) {
+ return resolve();
+ }
+
+ let variables = cloneDeep(environment?.variables);
+
+ // update existing values
+ variables = variables?.map?.(variable => ({
+ ...variable,
+ value: globalEnvironmentVariables?.[variable?.name]
+ }));
+
+ // add new env values
+ Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
+ let isAnExistingVariable = variables?.find(v => v?.name == key)
+ if (!isAnExistingVariable) {
+ variables.push({
+ uid: uuid(),
+ name: key,
+ value,
+ type: 'text',
+ secret: false,
+ enabled: true
+ });
+ }
+ });
+
+ environmentSchema
+ .validate(environment)
+ .then(() => ipcRenderer.invoke('renderer:save-global-environment', {
+ environmentUid,
+ variables
+ }))
+ .then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
+ .then(resolve)
+ .catch((error) => {
+ reject(error);
+ });
+ });
+}
+
+
+export default globalEnvironmentsSlice.reducer;
\ No newline at end of file
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
index 60b4e2df7..062f367ca 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
@@ -1,7 +1,7 @@
import toast from 'react-hot-toast';
import { createSlice } from '@reduxjs/toolkit';
import { getAppInstallDate } from 'utils/common/platform';
-
+import semver from 'semver';
const getReadNotificationIds = () => {
try {
let readNotificationIdsString = window.localStorage.getItem('bruno.notifications.read');
@@ -9,6 +9,7 @@ const getReadNotificationIds = () => {
return readNotificationIds;
} catch (err) {
toast.error('An error occurred while fetching read notifications');
+ return [];
}
};
@@ -26,6 +27,26 @@ const initialState = {
readNotificationIds: getReadNotificationIds() || []
};
+export const filterNotificationsByVersion = (notifications, currentVersion) => {
+ try {
+ if (!notifications) return [];
+
+ if (!currentVersion) return notifications;
+
+ return notifications.filter(notification => {
+ const { minVersion, maxVersion } = notification;
+ if (!minVersion && !maxVersion) return true;
+ if (!minVersion) return semver.lte(currentVersion, maxVersion);
+ if (!maxVersion) return semver.gte(currentVersion, minVersion);
+
+ return semver.gte(currentVersion, minVersion) && semver.lte(currentVersion, maxVersion);
+ });
+ } catch (error) {
+ console.error(error);
+ return [];
+ }
+};
+
export const notificationSlice = createSlice({
name: 'notifications',
initialState,
@@ -34,7 +55,6 @@ export const notificationSlice = createSlice({
state.loading = action.payload.fetching;
},
setNotifications: (state, action) => {
- console.log('notifications', notifications);
let notifications = action.payload.notifications || [];
let readNotificationIds = state.readNotificationIds;
@@ -58,14 +78,16 @@ export const notificationSlice = createSlice({
});
},
markNotificationAsRead: (state, action) => {
- if (state.readNotificationIds.includes(action.payload.notificationId)) return;
+ const { notificationId } = action.payload;
+
+ if (state.readNotificationIds.includes(notificationId)) return;
const notification = state.notifications.find(
- (notification) => notification.id === action.payload.notificationId
+ (notification) => notification.id === notificationId
);
if (!notification) return;
- state.readNotificationIds.push(action.payload.notificationId);
+ state.readNotificationIds.push(notificationId);
setReadNotificationsIds(state.readNotificationIds);
notification.read = true;
},
@@ -84,13 +106,14 @@ export const notificationSlice = createSlice({
export const { setNotifications, setFetchingStatus, markNotificationAsRead, markAllNotificationsAsRead } =
notificationSlice.actions;
-export const fetchNotifications = () => (dispatch, getState) => {
+export const fetchNotifications = ({currentVersion}) => (dispatch, getState) => {
return new Promise((resolve) => {
const { ipcRenderer } = window;
dispatch(setFetchingStatus(true));
ipcRenderer
.invoke('renderer:fetch-notifications')
.then((notifications) => {
+ notifications = filterNotificationsByVersion(notifications, currentVersion);
dispatch(setNotifications({ notifications }));
dispatch(setFetchingStatus(false));
resolve(notifications);
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/notifications.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.spec.js
new file mode 100644
index 000000000..80e84d9bd
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.spec.js
@@ -0,0 +1,133 @@
+const { filterNotificationsByVersion } = require('./notifications');
+
+describe('filterNotificationsByVersion - basic', () => {
+ it('should filter notifications by version', () => {
+ const notifications = [{ minVersion: '1.0.0', maxVersion: '1.1.0' }];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([{ minVersion: '1.0.0', maxVersion: '1.1.0' }]);
+ });
+
+ it('should gracefully handle no notifications', () => {
+ const notifications = [];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([]);
+ });
+
+ it('should gracefully handle notifications are undefined', () => {
+ const notifications = undefined;
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([]);
+ });
+
+ it('should gracefully handle scenario when no current version is provided', () => {
+ const notifications = [{ minVersion: '1.0.0', maxVersion: '1.1.0' }];
+ const filteredNotifications = filterNotificationsByVersion(notifications);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+
+ it('should gracefully handle scenario minVersion is undefined', () => {
+ const notifications = [{ minVersion: undefined, maxVersion: '1.1.0' }];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+
+ it('should gracefully handle scenario maxVersion is undefined', () => {
+ const notifications = [{ minVersion: '1.0.0', maxVersion: undefined }];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+
+ it('should gracefully handle scenario minVersion and maxVersion are undefined', () => {
+ const notifications = [{ minVersion: undefined, maxVersion: undefined }];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+});
+
+describe('filterNotificationsByVersion - semver', () => {
+ it('should filter out notifications outside version range', () => {
+ const notifications = [
+ { minVersion: '1.0.0', maxVersion: '1.1.0' }, // should be included
+ { minVersion: '2.0.0', maxVersion: '2.1.0' }, // should be filtered out
+ { minVersion: '0.5.0', maxVersion: '0.9.0' } // should be filtered out
+ ];
+ const currentVersion = '1.0.5';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([
+ { minVersion: '1.0.0', maxVersion: '1.1.0' }
+ ]);
+ });
+
+ it('should handle mixed valid and invalid version ranges', () => {
+ const notifications = [
+ { minVersion: '1.0.0', maxVersion: '2.0.0' }, // should be included
+ { minVersion: '3.0.0', maxVersion: '4.0.0' }, // should be filtered out
+ { minVersion: '1.5.0', maxVersion: '1.8.0' }, // should be included
+ { minVersion: '0.1.0', maxVersion: '0.5.0' } // should be filtered out
+ ];
+ const currentVersion = '1.6.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([
+ { minVersion: '1.0.0', maxVersion: '2.0.0' },
+ { minVersion: '1.5.0', maxVersion: '1.8.0' }
+ ]);
+ });
+
+ it('should handle edge cases of version ranges', () => {
+ const notifications = [
+ { minVersion: '1.0.0', maxVersion: '1.0.0' }, // should be included
+ { minVersion: '1.0.1', maxVersion: '2.0.0' }, // should be filtered out
+ { minVersion: '0.9.9', maxVersion: '1.0.0' } // should be included
+ ];
+ const currentVersion = '1.0.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([
+ { minVersion: '1.0.0', maxVersion: '1.0.0' },
+ { minVersion: '0.9.9', maxVersion: '1.0.0' }
+ ]);
+ });
+});
+
+describe('filterNotificationsByVersion - undefined version bounds', () => {
+ it('should include notifications when minVersion is undefined and current version is below maxVersion', () => {
+ const notifications = [
+ { minVersion: undefined, maxVersion: '2.0.0' }
+ ];
+ const currentVersion = '1.5.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+
+ it('should exclude notifications when minVersion is undefined and current version is above maxVersion', () => {
+ const notifications = [
+ { minVersion: undefined, maxVersion: '2.0.0' }
+ ];
+ const currentVersion = '2.1.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([]);
+ });
+
+ it('should include notifications when maxVersion is undefined and current version is above minVersion', () => {
+ const notifications = [
+ { minVersion: '1.0.0', maxVersion: undefined }
+ ];
+ const currentVersion = '2.0.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual(notifications);
+ });
+
+ it('should exclude notifications when maxVersion is undefined and current version is below minVersion', () => {
+ const notifications = [
+ { minVersion: '1.0.0', maxVersion: undefined }
+ ];
+ const currentVersion = '0.9.0';
+ const filteredNotifications = filterNotificationsByVersion(notifications, currentVersion);
+ expect(filteredNotifications).toEqual([]);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 74c503dad..219655d70 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
+import { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -19,32 +20,83 @@ export const tabsSlice = createSlice({
initialState,
reducers: {
addTab: (state, action) => {
- const alreadyExists = find(state.tabs, (tab) => tab.uid === action.payload.uid);
- if (alreadyExists) {
+ const { uid, collectionUid, type, requestPaneTab, preview } = action.payload;
+ const nonReplaceableTabTypes = [
+ "variables",
+ "collection-runner",
+ "security-settings",
+ ];
+
+ const existingTab = find(state.tabs, (tab) => tab.uid === uid);
+ if (existingTab) {
+ state.activeTabUid = existingTab.uid;
return;
}
- if (['variables', 'collection-settings', 'collection-runner'].includes(action.payload.type)) {
- const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
- if (tab) {
- state.activeTabUid = tab.uid;
+ if (nonReplaceableTabTypes.includes(type)) {
+ const existingTab = tabTypeAlreadyExists(state.tabs, collectionUid, type);
+ if (existingTab) {
+ state.activeTabUid = existingTab.uid;
return;
}
}
+ const lastTab = state.tabs[state.tabs.length - 1];
+ if (state.tabs.length > 0 && lastTab.preview) {
+ state.tabs[state.tabs.length - 1] = {
+ uid,
+ collectionUid,
+ requestPaneWidth: null,
+ requestPaneTab: requestPaneTab || 'params',
+ responsePaneTab: 'response',
+ type: type || 'request',
+ preview: preview !== undefined
+ ? preview
+ : !nonReplaceableTabTypes.includes(type),
+ ...(uid ? { folderUid: uid } : {})
+ }
+
+ state.activeTabUid = uid;
+ return
+ }
+
state.tabs.push({
- uid: action.payload.uid,
- collectionUid: action.payload.collectionUid,
+ uid,
+ collectionUid,
requestPaneWidth: null,
- requestPaneTab: action.payload.requestPaneTab || 'params',
+ requestPaneTab: requestPaneTab || 'params',
responsePaneTab: 'response',
- type: action.payload.type || 'request'
+ type: type || 'request',
+ ...(uid ? { folderUid: uid } : {}),
+ preview: preview !== undefined
+ ? preview
+ : !nonReplaceableTabTypes.includes(type)
});
- state.activeTabUid = action.payload.uid;
+ state.activeTabUid = uid;
},
focusTab: (state, action) => {
state.activeTabUid = action.payload.uid;
},
+ switchTab: (state, action) => {
+ if (!state.tabs || !state.tabs.length) {
+ state.activeTabUid = null;
+ return;
+ }
+
+ const direction = action.payload.direction;
+
+ const activeTabIndex = state.tabs.findIndex((t) => t.uid === state.activeTabUid);
+
+ let toBeActivatedTabIndex = 0;
+
+ if (direction == 'pageup') {
+ toBeActivatedTabIndex = (activeTabIndex - 1 + state.tabs.length) % state.tabs.length;
+ } else if (direction == 'pagedown') {
+ toBeActivatedTabIndex = (activeTabIndex + 1) % state.tabs.length;
+ }
+
+ state.activeTabUid = state.tabs[toBeActivatedTabIndex].uid;
+ },
updateRequestPaneTabWidth: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
@@ -101,6 +153,15 @@ export const tabsSlice = createSlice({
const collectionUid = action.payload.collectionUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
state.activeTabUid = null;
+ },
+ makeTabPermanent: (state, action) => {
+ const { uid } = action.payload;
+ const tab = find(state.tabs, (t) => t.uid === uid);
+ if (tab) {
+ tab.preview = false;
+ } else{
+ console.error("Tab not found!")
+ }
}
}
});
@@ -108,11 +169,13 @@ export const tabsSlice = createSlice({
export const {
addTab,
focusTab,
+ switchTab,
updateRequestPaneTabWidth,
updateRequestPaneTab,
updateResponsePaneTab,
closeTabs,
- closeAllCollectionTabs
+ closeAllCollectionTabs,
+ makeTabPermanent
} = tabsSlice.actions;
-export default tabsSlice.reducer;
+export default tabsSlice.reducer;
\ No newline at end of file
diff --git a/packages/bruno-app/src/providers/Toaster/index.js b/packages/bruno-app/src/providers/Toaster/index.js
index 1ae25764c..10dab3297 100644
--- a/packages/bruno-app/src/providers/Toaster/index.js
+++ b/packages/bruno-app/src/providers/Toaster/index.js
@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
export const ToastProvider = (props) => {
const { storedTheme } = useTheme();
- const toastOptions = { duration: 2000 };
+ const toastOptions = {
+ duration: 2000,
+ style: {
+ // Break long word like file-path, URL etc. to prevent overflow
+ overflowWrap: 'anywhere'
+ }
+ };
+
if (storedTheme === 'dark') {
toastOptions.style = {
+ ...toastOptions.style,
borderRadius: '10px',
background: '#3d3d3d',
color: '#fff'
diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js
new file mode 100644
index 000000000..76aa67365
--- /dev/null
+++ b/packages/bruno-app/src/selectors/tab.js
@@ -0,0 +1,9 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+export const isTabForItemActive = ({ itemUid }) => createSelector([
+ (state) => state.tabs?.activeTabUid
+], (activeTabUid) => activeTabUid === itemUid);
+
+export const isTabForItemPresent = ({ itemUid }) => createSelector([
+ (state) => state.tabs.tabs,
+], (tabs) => tabs.some((tab) => tab.uid === itemUid));
\ No newline at end of file
diff --git a/packages/bruno-app/src/styles/globals.css b/packages/bruno-app/src/styles/globals.css
index 29e9196ea..ef2de6bbe 100644
--- a/packages/bruno-app/src/styles/globals.css
+++ b/packages/bruno-app/src/styles/globals.css
@@ -24,6 +24,117 @@
--color-method-head: rgb(52 52 52);
}
+:root,.graphiql-container,.CodeMirror-info,.CodeMirror-lint-tooltip,reach-portal {
+ /* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
+ /* Colors */
+ --color-primary: 0, 0%, 0% !important;
+ --color-secondary: 0, 0%, 0% !important;
+ --color-tertiary: 0, 0%, 0% !important;
+ --color-info: 0, 0%, 0% !important;
+ --color-success: 0, 0%, 0% !important;
+ --color-warning: 0, 0%, 0% !important;
+ --color-error: 0, 0%, 0% !important;
+ --color-neutral: 0, 0%, 0% !important;
+ --color-base: 0, 0%, 100% !important;
+
+ /* Color alpha values */
+ --alpha-secondary: 0.76 !important;
+ --alpha-tertiary: 0.5 !important;
+ --alpha-background-heavy: 0.15 !important;
+ --alpha-background-medium: 0.1 !important;
+ --alpha-background-light: 0.07 !important;
+
+ --font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
+ --font-family-mono: 'Fira Code', monospace;
+ --font-size-hint: .75rem;
+ --font-size-inline-code: .8125rem;
+ --font-size-body: .8rem;
+ --font-size-h4: 1.125rem;
+ --font-size-h3: 1.375rem;
+ --font-size-h2: 1.8125rem;
+ --font-weight-regular: 400;
+ --font-weight-medium: 500;
+ --line-height: 1.5;
+ --px-2: 0px;
+ --px-4: 0px;
+ --px-6: 2px;
+ --px-8: 8px;
+ --px-10: 10px;
+ --px-12: 12px;
+ --px-16: 16px;
+ --px-20: 20px;
+ --px-24: 24px;
+ --border-radius-2: 0px !important;
+ --border-radius-4: 0px !important;
+ --border-radius-8: 0px !important;
+ --border-radius-12: 0px !important;
+ --popover-box-shadow: 0px 0px 1px #000 !important;
+ --popover-border: none;
+ --sidebar-width: 60px;
+ --toolbar-width: 40px;
+ --session-header-height: 51px
+}
+
+/* Required CSS variables after upgrading GraphiQL from v1.5.9 to v2.4.7 */
+.graphiql-container, .CodeMirror-info, .CodeMirror-lint-tooltip, reach-portal {
+ /* General Colors */
+ --color-primary: 0, 0%, 0% !important;
+ --color-secondary: 0, 0%, 0% !important;
+ --color-tertiary: 0, 0%, 0% !important;
+ --color-info: 0, 0%, 0% !important;
+ --color-success: 0, 0%, 0% !important;
+ --color-warning: 0, 0%, 0% !important;
+ --color-error: 0, 0%, 0% !important;
+ --color-base: 0, 0%, 100% !important;
+ --color-neutral: 0, 0%, 60% !important;
+
+ /* Color alpha values */
+ --alpha-secondary: 0.76 !important;
+ --alpha-tertiary: 0.5 !important;
+ --alpha-background-heavy: 0.15 !important;
+ --alpha-background-medium: 0.1 !important;
+ --alpha-background-light: 0.07 !important;
+
+ --font-family: Consolas,Inconsolata,Droid Sans Mono,Monaco,monospace;
+ --font-family-mono: 'Fira Code', monospace;
+ --font-size-hint: .75rem;
+ --font-size-inline-code: .8125rem;
+ --font-size-body: .9375rem;
+ --font-size-h4: 1.125rem;
+ --font-size-h3: 1.375rem;
+ --font-size-h2: 1.8125rem;
+ --font-weight-regular: 400;
+ --font-weight-medium: 500;
+ --line-height: 1.5;
+ --px-2: 2px !important;
+ --px-4: 4px !important;
+ --px-6: 6px !important;
+ --px-8: 8px !important;
+ --px-10: 10px !important;
+ --px-12: 12px !important;
+ --px-16: 16px !important;
+ --px-20: 20px !important;
+ --px-24: 24px !important;
+ --border-radius-2: 2px !important;
+ --border-radius-4: 2px !important;
+ --border-radius-8: 2px !important;
+ --border-radius-12: 2px !important;
+ --popover-box-shadow: 0px 0px 1px #000 !important;
+ --popover-border: none;
+ --sidebar-width: 60px;
+ --toolbar-width: 40px;
+ --session-header-height: 51px
+}
+
+.CodeMirror-dialog {
+ --px-4: 0px !important;
+ --px-12: 2px !important;
+}
+
+.graphiql-container {
+ background: transparent !important;
+}
+
html,
body {
margin: 0;
@@ -34,7 +145,7 @@ body {
font-kerning: none;
text-rendering: optimizeSpeed;
letter-spacing: normal;
- font-family: Inter, sans-serif !important;
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
overflow-x: hidden;
}
@@ -58,6 +169,15 @@ body::-webkit-scrollbar-thumb,
border-radius: 5rem;
}
+/*
+ * Mac-specific scrollbar styling
+ * This ensures that scrollbars are only visible when the user starts to scroll,
+ * providing a cleaner and more minimalistic appearance.
+ */
+body.os-mac * {
+ scrollbar-width: thin;
+}
+
/*
* todo: this will be supported in the future to be changed via applying a theme
* making all the checkboxes and radios bigger
diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js
index bb1001f31..04ee6134e 100644
--- a/packages/bruno-app/src/themes/dark.js
+++ b/packages/bruno-app/src/themes/dark.js
@@ -20,7 +20,11 @@ const darkTheme = {
input: {
bg: 'rgb(65, 65, 65)',
border: 'rgb(65, 65, 65)',
- focusBorder: 'rgb(65, 65, 65)'
+ focusBorder: 'rgb(65, 65, 65)',
+ placeholder: {
+ color: '#a2a2a2',
+ opacity: 0.75
+ }
},
variables: {
@@ -110,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: {
@@ -154,7 +176,7 @@ const darkTheme = {
modal: {
title: {
color: '#ccc',
- bg: 'rgb(48, 48, 49)',
+ bg: 'rgb(38, 38, 39)',
iconColor: '#ccc'
},
body: {
@@ -257,6 +279,18 @@ const darkTheme = {
scrollbar: {
color: 'rgb(52 51 49)'
+ },
+
+ dragAndDrop: {
+ border: '#666666',
+ borderStyle: '2px solid',
+ hoverBg: 'rgba(102, 102, 102, 0.08)',
+ transition: 'all 0.1s ease'
+ },
+ infoTip: {
+ bg: '#1f1f1f',
+ border: '#333333',
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)'
}
};
diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js
index a130f2513..e95b0e45e 100644
--- a/packages/bruno-app/src/themes/light.js
+++ b/packages/bruno-app/src/themes/light.js
@@ -20,7 +20,11 @@ const lightTheme = {
input: {
bg: 'white',
border: '#ccc',
- focusBorder: '#8b8b8b'
+ focusBorder: '#8b8b8b',
+ placeholder: {
+ color: '#a2a2a2',
+ opacity: 0.8
+ }
},
menubar: {
@@ -110,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: {
@@ -261,6 +280,18 @@ const lightTheme = {
scrollbar: {
color: 'rgb(152 151 149)'
+ },
+
+ dragAndDrop: {
+ border: '#8b8b8b', // Using the same gray as focusBorder from input
+ borderStyle: '2px solid',
+ hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
+ transition: 'all 0.1s ease'
+ },
+ infoTip: {
+ bg: 'white',
+ border: '#e0e0e0',
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
}
};
diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js
new file mode 100644
index 000000000..981d6cec2
--- /dev/null
+++ b/packages/bruno-app/src/utils/codegenerator/auth.js
@@ -0,0 +1,30 @@
+import get from 'lodash/get';
+
+export const getAuthHeaders = (collectionRootAuth, requestAuth) => {
+ const auth = collectionRootAuth && ['inherit'].includes(requestAuth?.mode) ? collectionRootAuth : requestAuth;
+
+ switch (auth.mode) {
+ case 'basic':
+ const username = get(auth, 'basic.username', '');
+ const password = get(auth, 'basic.password', '');
+ const basicToken = Buffer.from(`${username}:${password}`).toString('base64');
+
+ return [
+ {
+ enabled: true,
+ name: 'Authorization',
+ value: `Basic ${basicToken}`
+ }
+ ];
+ case 'bearer':
+ return [
+ {
+ enabled: true,
+ name: 'Authorization',
+ value: `Bearer ${get(auth, 'bearer.token', '')}`
+ }
+ ];
+ default:
+ return [];
+ }
+};
diff --git a/packages/bruno-app/src/utils/codegenerator/har.js b/packages/bruno-app/src/utils/codegenerator/har.js
index 0f2656370..110f82db5 100644
--- a/packages/bruno-app/src/utils/codegenerator/har.js
+++ b/packages/bruno-app/src/utils/codegenerator/har.js
@@ -2,62 +2,125 @@ const createContentType = (mode) => {
switch (mode) {
case 'json':
return 'application/json';
+ case 'text':
+ return 'text/plain';
case 'xml':
return 'application/xml';
+ case 'sparql':
+ return 'application/sparql-query';
case 'formUrlEncoded':
return 'application/x-www-form-urlencoded';
+ case 'graphql':
+ return 'application/json';
case 'multipartForm':
return 'multipart/form-data';
+ case 'file':
+ return 'application/octet-stream';
default:
return '';
}
};
-const createHeaders = (headers) => {
- return headers
+/**
+ * Creates a list of enabled headers for the request, ensuring no duplicate content-type headers.
+ *
+ * @param {Object} request - The request object.
+ * @param {Object[]} headers - The array of header objects, each containing name, value, and enabled properties.
+ * @returns {Object[]} - An array of enabled headers with normalized names and values.
+ */
+const createHeaders = (request, headers) => {
+ const enabledHeaders = headers
.filter((header) => header.enabled)
.map((header) => ({
- name: header.name,
+ name: header.name.toLowerCase(),
value: header.value
}));
+
+ const contentType = createContentType(request.body?.mode);
+ if (contentType !== '' && !enabledHeaders.some((header) => header.name === 'content-type')) {
+ enabledHeaders.push({ name: 'content-type', value: contentType });
+ }
+
+ return enabledHeaders;
};
const createQuery = (queryParams = []) => {
return queryParams
- .filter((param) => param.enabled)
+ .filter((param) => param.enabled && param.type === 'query')
.map((param) => ({
name: param.name,
value: param.value
}));
};
-const createPostData = (body) => {
+const createPostData = (body, type) => {
+ if (type === 'graphql-request') {
+ return {
+ mimeType: 'application/json',
+ text: JSON.stringify(body[body.mode])
+ };
+ }
+
const contentType = createContentType(body.mode);
- if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
- return {
- mimeType: contentType,
- params: body[body.mode]
- .filter((param) => param.enabled)
- .map((param) => ({ name: param.name, value: param.value }))
- };
- } else {
- return {
- mimeType: contentType,
- text: body[body.mode]
- };
+
+ switch (body.mode) {
+ case 'formUrlEncoded':
+ return {
+ mimeType: contentType,
+ text: new URLSearchParams(
+ body[body.mode]
+ .filter((param) => param.enabled)
+ .reduce((acc, param) => {
+ acc[param.name] = param.value;
+ return acc;
+ }, {})
+ ).toString(),
+ params: body[body.mode]
+ .filter((param) => param.enabled)
+ .map((param) => ({
+ name: param.name,
+ value: param.value
+ }))
+ };
+ case 'multipartForm':
+ return {
+ mimeType: contentType,
+ params: body[body.mode]
+ .filter((param) => param.enabled)
+ .map((param) => ({
+ name: param.name,
+ value: param.value,
+ ...(param.type === 'file' && { fileName: param.value })
+ }))
+ };
+ case 'file':
+ return {
+ mimeType: body[body.mode].filter((param) => param.enabled)[0].contentType,
+ params: body[body.mode]
+ .filter((param) => param.selected)
+ .map((param) => ({
+ value: param.filePath,
+ }))
+ };
+ default:
+ return {
+ mimeType: contentType,
+ text: body[body.mode]
+ };
}
};
-export const buildHarRequest = ({ request, headers }) => {
+export const buildHarRequest = ({ request, headers, type }) => {
return {
method: request.method,
url: encodeURI(request.url),
httpVersion: 'HTTP/1.1',
cookies: [],
- headers: createHeaders(headers),
+ headers: createHeaders(request, headers),
queryString: createQuery(request.params),
- postData: createPostData(request.body),
+ postData: createPostData(request.body, type),
headersSize: 0,
- bodySize: 0
+ bodySize: 0,
+ binary: true
};
};
diff --git a/packages/bruno-app/src/utils/codegenerator/targets.js b/packages/bruno-app/src/utils/codegenerator/targets.js
new file mode 100644
index 000000000..95b222dbc
--- /dev/null
+++ b/packages/bruno-app/src/utils/codegenerator/targets.js
@@ -0,0 +1,31 @@
+import { targets } from 'httpsnippet';
+
+export const getLanguages = () => {
+ const allLanguages = [];
+ for (const target of Object.values(targets)) {
+ const { key, title } = target.info;
+ const clients = Object.keys(target.clientsById);
+ const languages =
+ (clients.length === 1)
+ ? [{
+ name: title,
+ target: key,
+ client: clients[0]
+ }]
+ : clients.map(client => ({
+ name: `${title}-${client}`,
+ target: key,
+ client
+ }));
+ allLanguages.push(...languages);
+
+ // Move "Shell-curl" to the top of the array
+ const shellCurlIndex = allLanguages.findIndex(lang => lang.name === "Shell-curl");
+ if (shellCurlIndex !== -1) {
+ const [shellCurl] = allLanguages.splice(shellCurlIndex, 1);
+ allLanguages.unshift(shellCurl);
+ }
+ }
+
+ return allLanguages;
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js
index f1e88537b..9c068f20e 100644
--- a/packages/bruno-app/src/utils/codemirror/autocomplete.js
+++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js
@@ -1,5 +1,5 @@
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
diff --git a/packages/bruno-app/src/utils/codemirror/autocompleteConstants.js b/packages/bruno-app/src/utils/codemirror/autocompleteConstants.js
new file mode 100644
index 000000000..f0c8a9aa2
--- /dev/null
+++ b/packages/bruno-app/src/utils/codemirror/autocompleteConstants.js
@@ -0,0 +1,56 @@
+export const MimeTypes = [
+ 'application/atom+xml',
+ 'application/ecmascript',
+ 'application/json',
+ 'application/vnd.api+json',
+ 'application/javascript',
+ 'application/octet-stream',
+ 'application/ogg',
+ 'application/pdf',
+ 'application/postscript',
+ 'application/rdf+xml',
+ 'application/rss+xml',
+ 'application/soap+xml',
+ 'application/font-woff',
+ 'application/x-yaml',
+ 'application/xhtml+xml',
+ 'application/xml',
+ 'application/xml-dtd',
+ 'application/xop+xml',
+ 'application/zip',
+ 'application/gzip',
+ 'application/graphql',
+ 'application/x-www-form-urlencoded',
+ 'audio/basic',
+ 'audio/L24',
+ 'audio/mp4',
+ 'audio/mpeg',
+ 'audio/ogg',
+ 'audio/vorbis',
+ 'audio/vnd.rn-realaudio',
+ 'audio/vnd.wave',
+ 'audio/webm',
+ 'image/gif',
+ 'image/jpeg',
+ 'image/pjpeg',
+ 'image/png',
+ 'image/svg+xml',
+ 'image/tiff',
+ 'message/http',
+ 'message/imdn+xml',
+ 'message/partial',
+ 'message/rfc822',
+ 'multipart/mixed',
+ 'multipart/alternative',
+ 'multipart/related',
+ 'multipart/form-data',
+ 'multipart/signed',
+ 'multipart/encrypted',
+ 'text/cmd',
+ 'text/css',
+ 'text/csv',
+ 'text/html',
+ 'text/plain',
+ 'text/vcard',
+ 'text/xml'
+];
diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
index d37e10bb6..cef99a22d 100644
--- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
+++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
@@ -6,8 +6,10 @@
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
*/
+import { interpolate } from '@usebruno/common';
+
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
if (!SERVER_RENDERED) {
@@ -18,16 +20,32 @@ if (!SERVER_RENDERED) {
if (!str || !str.length || typeof str !== 'string') {
return;
}
- // str is of format {{variableName}}, extract variableName
- // we are seeing that from the gql query editor, the token string is of format variableName
- const variableName = str.replace('{{', '').replace('}}', '').trim();
- const variableValue = get(options.variables, variableName);
+
+ // str is of format {{variableName}} or :variableName, extract variableName
+ let variableName;
+ let variableValue;
+
+ if (str.startsWith('{{')) {
+ variableName = str.replace('{{', '').replace('}}', '').trim();
+ variableValue = interpolate(get(options.variables, variableName), options.variables);
+ } else if (str.startsWith('/:')) {
+ variableName = str.replace('/:', '').trim();
+ variableValue =
+ options.variables && options.variables.pathParams ? options.variables.pathParams[variableName] : undefined;
+ }
+
+ if (variableValue === undefined) {
+ return;
+ }
const into = document.createElement('div');
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
-
- descriptionDiv.appendChild(document.createTextNode(variableValue));
+ if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
+ descriptionDiv.appendChild(document.createTextNode('*****'));
+ } else {
+ descriptionDiv.appendChild(document.createTextNode(variableValue));
+ }
into.appendChild(descriptionDiv);
return into;
@@ -72,9 +90,6 @@ if (!SERVER_RENDERED) {
const box = target.getBoundingClientRect();
- const hoverTime = getHoverTime(cm);
- state.hoverTimeout = setTimeout(onHover, hoverTime);
-
const onMouseMove = function () {
clearTimeout(state.hoverTimeout);
state.hoverTimeout = setTimeout(onHover, hoverTime);
@@ -94,6 +109,9 @@ if (!SERVER_RENDERED) {
onMouseHover(cm, box);
};
+ const hoverTime = getHoverTime(cm);
+ state.hoverTimeout = setTimeout(onHover, hoverTime);
+
CodeMirror.on(document, 'mousemove', onMouseMove);
CodeMirror.on(cm.getWrapperElement(), 'mouseout', onMouseOut);
}
diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.js
index a3a56857e..0038406aa 100644
--- a/packages/bruno-app/src/utils/codemirror/javascript-lint.js
+++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.js
@@ -5,8 +5,10 @@
* Copyright (C) 2017 by Marijn Haverbeke and others
*/
+import { JSHINT } from 'jshint';
+
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@@ -19,6 +21,31 @@ if (!SERVER_RENDERED) {
}
return [];
}
+
+ // Set default options for Bruno
+ const defaultOptions = {
+ esversion: 11,
+ expr: true,
+ asi: true,
+ undef: true,
+ browser: true,
+ devel: true,
+ module: true,
+ node: true,
+ predef: {
+ 'bru': false,
+ 'req': false,
+ 'res': false,
+ 'test': false,
+ 'expect': false,
+ 'require': false,
+ 'module': false
+ }
+ };
+
+ // Merge provided options with defaults
+ options = Object.assign({}, defaultOptions, options);
+
if (!options.indent)
// JSHint error.character actually is a column index, this fixes underlining on lines using tabs for indentation
options.indent = 1; // JSHint default value is 4
@@ -30,15 +57,19 @@ if (!SERVER_RENDERED) {
* Filter out errors due to top level awaits
* See https://github.com/usebruno/bruno/issues/1214
*
+ * - E058: Missing semicolon at top level await
+ * codemirror error: "Missing semicolon."
+ * - W024: 'await' used as identifier (JSHint doesn't recognize top-level await syntax)
+ * codemirror error: "Expected an identifier and instead saw 'await' (a reserved word)."
+ *
* Once JSHINT top level await support is added, this file can be removed
* and we can use the default javascript-lint addon from codemirror
*/
errors = filter(errors, (error) => {
- if (error.code === 'E058') {
+ if (error.code === 'E058' || error.code === 'W024') {
if (
error.evidence &&
error.evidence.includes('await') &&
- error.reason === 'Missing semicolon.' &&
error.scope === '(main)'
) {
return false;
@@ -47,6 +78,18 @@ if (!SERVER_RENDERED) {
return true;
}
+ /*
+ * Filter out errors due to atob/btoa redefinition
+ *
+ * - W079: Redefinition of '{a}'
+ * This JSHint warning triggers when a variable name conflicts with a built-in global.
+ * We filter this for atob/btoa to allow explicit requires in Node.js environments
+ * where these browser functions might not be available.
+ */
+ if (error.code === 'W079' && (error.a === 'atob' || error.a === 'btoa')) {
+ return false;
+ }
+
return true;
});
diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js
index 17c979fe6..3d15fdd07 100644
--- a/packages/bruno-app/src/utils/collections/export.js
+++ b/packages/bruno-app/src/utils/collections/export.js
@@ -14,6 +14,7 @@ export const deleteUidsInItems = (items) => {
each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
+ each(get(item, 'request.body.file'), (param) => delete param.uid);
}
if (item.items && item.items.length) {
@@ -29,9 +30,6 @@ export const deleteUidsInItems = (items) => {
export const transformItem = (items = []) => {
each(items, (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
- item.request.query = item.request.params;
- delete item.request.params;
-
if (item.type === 'graphql-request') {
item.type = 'graphql';
}
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 615d81952..61ce02f50 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1,15 +1,6 @@
-import get from 'lodash/get';
-import each from 'lodash/each';
-import find from 'lodash/find';
-import findIndex from 'lodash/findIndex';
-import isString from 'lodash/isString';
-import map from 'lodash/map';
-import filter from 'lodash/filter';
-import sortBy from 'lodash/sortBy';
-import isEqual from 'lodash/isEqual';
-import cloneDeep from 'lodash/cloneDeep';
+import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
-import path from 'path';
+import path from 'utils/common/path';
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -33,7 +24,7 @@ export const addDepth = (items = []) => {
depth(items, 1);
};
-export const collapseCollection = (collection) => {
+export const collapseAllItemsInCollection = (collection) => {
collection.collapsed = true;
const collapseItem = (items) => {
@@ -46,7 +37,7 @@ export const collapseCollection = (collection) => {
});
};
- collapseItem(collection.items, 1);
+ collapseItem(collection.items);
};
export const sortItems = (collection) => {
@@ -107,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
+export const findParentItemInCollectionByPathname = (collection, pathname) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return find(flattenedItems, (item) => {
+ return item.items && find(item.items, (i) => i.pathname === pathname);
+ });
+};
+
export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items);
@@ -131,89 +130,33 @@ export const findEnvironmentInCollection = (collection, envUid) => {
return find(collection.environments, (e) => e.uid === envUid);
};
-export const moveCollectionItem = (collection, draggedItem, targetItem) => {
- let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
+export const findEnvironmentInCollectionByName = (collection, name) => {
+ return find(collection.environments, (e) => e.name === name);
+};
- if (draggedItemParent) {
- draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
- draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
- draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
- } else {
- collection.items = sortBy(collection.items, (item) => item.seq);
- collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
- }
-
- if (targetItem.type === 'folder') {
- targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
- targetItem.items.push(draggedItem);
- draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
- } else {
- let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
-
- if (targetItemParent) {
- targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
- let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
- targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
- draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
- } else {
- collection.items = sortBy(collection.items, (item) => item.seq);
- let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
- collection.items.splice(targetItemIndex + 1, 0, draggedItem);
- draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
+export const areItemsLoading = (folder) => {
+ let flattenedItems = flattenItems(folder.items);
+ return flattenedItems?.reduce((isLoading, i) => {
+ if (i?.loading) {
+ isLoading = true;
}
- }
-};
+ return isLoading;
+ }, false);
+}
-export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
- let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
-
- // If the dragged item is already at the root of the collection, do nothing
- if (!draggedItemParent) {
- return;
- }
-
- draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
- draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
- collection.items = sortBy(collection.items, (item) => item.seq);
- collection.items.push(draggedItem);
- if (draggedItem.type == 'folder') {
- draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
- } else {
- draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
- }
-};
-
-export const getItemsToResequence = (parent, collection) => {
- let itemsToResequence = [];
-
- if (!parent) {
- let index = 1;
- each(collection.items, (item) => {
- if (isItemARequest(item)) {
- itemsToResequence.push({
- pathname: item.pathname,
- seq: index++
- });
- }
- });
- return itemsToResequence;
- }
-
- if (parent.items && parent.items.length) {
- let index = 1;
- each(parent.items, (item) => {
- if (isItemARequest(item)) {
- itemsToResequence.push({
- pathname: item.pathname,
- seq: index++
- });
- }
- });
- return itemsToResequence;
- }
-
- return itemsToResequence;
-};
+export const getItemsLoadStats = (folder) => {
+ let loadingCount = 0;
+ let flattenedItems = flattenItems(folder.items);
+ flattenedItems?.forEach(i => {
+ if(i?.loading) {
+ loadingCount += 1;
+ }
+ });
+ return {
+ loading: loadingCount,
+ total: flattenedItems?.length
+ };
+}
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
@@ -228,13 +171,14 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
- const copyQueryParams = (params) => {
+ const copyParams = (params) => {
return map(params, (param) => {
return {
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
+ type: param.type,
enabled: param.enabled
};
});
@@ -265,6 +209,17 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
});
};
+ const copyFileParams = (params = []) => {
+ return map(params, (param) => {
+ return {
+ uid: param.uid,
+ filePath: param.filePath,
+ contentType: param.contentType,
+ selected: param.selected
+ }
+ });
+ }
+
const copyItems = (sourceItems, destItems) => {
each(sourceItems, (si) => {
if (!isItemAFolder(si) && !isItemARequest(si) && si.type !== 'js') {
@@ -275,6 +230,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
uid: si.uid,
type: si.type,
name: si.name,
+ filename: si.filename,
seq: si.seq
};
@@ -283,7 +239,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
url: si.request.url,
method: si.request.method,
headers: copyHeaders(si.request.headers),
- params: copyQueryParams(si.request.params),
+ params: copyParams(si.request.params),
body: {
mode: si.request.body.mode,
json: si.request.body.json,
@@ -292,12 +248,14 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
graphql: si.request.body.graphql,
sparql: si.request.body.sparql,
formUrlEncoded: copyFormUrlEncodedParams(si.request.body.formUrlEncoded),
- multipartForm: copyMultipartFormParams(si.request.body.multipartForm)
+ multipartForm: copyMultipartFormParams(si.request.body.multipartForm),
+ file: copyFileParams(si.request.body.file)
},
script: si.request.script,
vars: si.request.vars,
assertions: si.request.assertions,
- tests: si.request.tests
+ tests: si.request.tests,
+ docs: si.request.docs
};
// Handle auth object dynamically
@@ -333,6 +291,13 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
password: get(si.request, 'auth.digest.password', '')
};
break;
+ case 'ntlm':
+ di.request.auth.ntlm = {
+ username: get(si.request, 'auth.ntlm.username', ''),
+ password: get(si.request, 'auth.ntlm.password', ''),
+ domain: get(si.request, 'auth.ntlm.domain', '')
+ };
+ break;
case 'oauth2':
let grantType = get(si.request, 'auth.oauth2.grantType', '');
switch (grantType) {
@@ -340,11 +305,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
+ refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
username: get(si.request, 'auth.oauth2.username', ''),
password: get(si.request, 'auth.oauth2.password', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
- scope: get(si.request, 'auth.oauth2.scope', '')
+ scope: get(si.request, 'auth.oauth2.scope', ''),
+ credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
+ credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
+ tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
+ tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
+ tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
+ autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
case 'authorization_code':
@@ -353,23 +326,52 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
callbackUrl: get(si.request, 'auth.oauth2.callbackUrl', ''),
authorizationUrl: get(si.request, 'auth.oauth2.authorizationUrl', ''),
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
+ refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
scope: get(si.request, 'auth.oauth2.scope', ''),
- pkce: get(si.request, 'auth.oauth2.pkce', false)
+ credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
+ pkce: get(si.request, 'auth.oauth2.pkce', false),
+ credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
+ tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
+ tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
+ tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
+ autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
case 'client_credentials':
di.request.auth.oauth2 = {
grantType: grantType,
accessTokenUrl: get(si.request, 'auth.oauth2.accessTokenUrl', ''),
+ refreshTokenUrl: get(si.request, 'auth.oauth2.refreshTokenUrl', ''),
clientId: get(si.request, 'auth.oauth2.clientId', ''),
clientSecret: get(si.request, 'auth.oauth2.clientSecret', ''),
- scope: get(si.request, 'auth.oauth2.scope', '')
+ scope: get(si.request, 'auth.oauth2.scope', ''),
+ credentialsPlacement: get(si.request, 'auth.oauth2.credentialsPlacement', 'body'),
+ credentialsId: get(si.request, 'auth.oauth2.credentialsId', 'credentials'),
+ tokenPlacement: get(si.request, 'auth.oauth2.tokenPlacement', 'header'),
+ tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', 'Bearer'),
+ tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
+ autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
};
break;
}
break;
+ case 'apikey':
+ di.request.auth.apikey = {
+ key: get(si.request, 'auth.apikey.key', ''),
+ value: get(si.request, 'auth.apikey.value', ''),
+ placement: get(si.request, 'auth.apikey.placement', 'header')
+ };
+ break;
+ case 'wsse':
+ di.request.auth.wsse = {
+ username: get(si.request, 'auth.wsse.username', ''),
+ password: get(si.request, 'auth.wsse.password', '')
+ };
+ break;
default:
break;
}
@@ -379,6 +381,61 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
}
}
+ if (si.type == 'folder' && si?.root) {
+ di.root = {
+ request: {}
+ };
+
+ let { request, meta, docs } = si?.root || {};
+ let { headers, script = {}, vars = {}, tests } = request || {};
+
+ // folder level headers
+ if (headers?.length) {
+ di.root.request.headers = headers;
+ }
+ // folder level script
+ if (Object.keys(script)?.length) {
+ di.root.request.script = {};
+ if (script?.req?.length) {
+ di.root.request.script.req = script?.req;
+ }
+ if (script?.res?.length) {
+ di.root.request.script.res = script?.res;
+ }
+ }
+ // folder level vars
+ if (Object.keys(vars)?.length) {
+ di.root.request.vars = {};
+ if (vars?.req?.length) {
+ di.root.request.vars.req = vars?.req;
+ }
+ if (vars?.res?.length) {
+ di.root.request.vars.res = vars?.res;
+ }
+ }
+ // folder level tests
+ if (tests?.length) {
+ di.root.request.tests = tests;
+ }
+
+ // folder level docs
+ if (docs?.length) {
+ di.root.docs = docs;
+ }
+
+ if (meta?.name) {
+ di.root.meta = {};
+ di.root.meta.name = meta?.name;
+ di.root.meta.seq = meta?.seq;
+ }
+ if (!Object.keys(di.root.request)?.length) {
+ delete di.root.request;
+ }
+ if (!Object.keys(di.root)?.length) {
+ delete di.root;
+ }
+ }
+
if (si.type === 'js') {
di.fileContent = si.raw;
}
@@ -402,6 +459,60 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
collectionToSave.activeEnvironmentUid = collection.activeEnvironmentUid;
collectionToSave.environments = collection.environments || [];
+ collectionToSave.root = {
+ request: {}
+ };
+
+ let { request, docs, meta } = collection?.root || {};
+ let { auth, headers, script = {}, vars = {}, tests } = request || {};
+
+ // collection level auth
+ if (auth?.mode) {
+ collectionToSave.root.request.auth = auth;
+ }
+ // collection level headers
+ if (headers?.length) {
+ collectionToSave.root.request.headers = headers;
+ }
+ // collection level script
+ if (Object.keys(script)?.length) {
+ collectionToSave.root.request.script = {};
+ if (script?.req?.length) {
+ collectionToSave.root.request.script.req = script?.req;
+ }
+ if (script?.res?.length) {
+ collectionToSave.root.request.script.res = script?.res;
+ }
+ }
+ // collection level vars
+ if (Object.keys(vars)?.length) {
+ collectionToSave.root.request.vars = {};
+ if (vars?.req?.length) {
+ collectionToSave.root.request.vars.req = vars?.req;
+ }
+ if (vars?.res?.length) {
+ collectionToSave.root.request.vars.res = vars?.res;
+ }
+ }
+ // collection level tests
+ if (tests?.length) {
+ collectionToSave.root.request.tests = tests;
+ }
+ // collection level docs
+ if (docs?.length) {
+ collectionToSave.root.docs = docs;
+ }
+ if (meta?.name) {
+ collectionToSave.root.meta = {};
+ collectionToSave.root.meta.name = meta?.name;
+ }
+ if (!Object.keys(collectionToSave.root.request)?.length) {
+ delete collectionToSave.root.request;
+ }
+ if (!Object.keys(collectionToSave.root)?.length) {
+ delete collectionToSave.root;
+ }
+
collectionToSave.brunoConfig = cloneDeep(collection?.brunoConfig);
// delete proxy password if present
@@ -441,6 +552,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
+ type: param.type,
enabled: param.enabled
});
});
@@ -515,6 +627,10 @@ export const humanizeRequestBodyMode = (mode) => {
label = 'SPARQL';
break;
}
+ case 'file': {
+ label = 'File / Binary';
+ break;
+ }
case 'formUrlEncoded': {
label = 'Form URL Encoded';
break;
@@ -551,10 +667,38 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'Digest Auth';
break;
}
+ case 'ntlm': {
+ label = 'NTLM';
+ break;
+ }
case 'oauth2': {
label = 'OAuth 2.0';
break;
}
+ case 'wsse': {
+ label = 'WSSE Auth';
+ break;
+ }
+ case 'apikey': {
+ label = 'API Key';
+ break;
+ }
+ }
+
+ return label;
+};
+
+export const humanizeRequestAPIKeyPlacement = (placement) => {
+ let label = 'Header';
+ switch (placement) {
+ case 'header': {
+ label = 'Header';
+ break;
+ }
+ case 'queryparams': {
+ label = 'Query Params';
+ break;
+ }
}
return label;
@@ -587,6 +731,7 @@ export const refreshUidsInItem = (item) => {
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
+ each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
return item;
};
@@ -597,11 +742,13 @@ export const deleteUidsInItem = (item) => {
const headers = get(item, 'request.headers', []);
const bodyFormUrlEncoded = get(item, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(item, 'request.body.multipartForm', []);
+ const file = get(item, 'request.body.file', []);
params.forEach((param) => delete param.uid);
headers.forEach((header) => delete header.uid);
bodyFormUrlEncoded.forEach((param) => delete param.uid);
bodyMultipartForm.forEach((param) => delete param.uid);
+ file.forEach((param) => delete param.uid);
return item;
};
@@ -639,6 +786,32 @@ export const getDefaultRequestPaneTab = (item) => {
}
};
+export const getGlobalEnvironmentVariables = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
+ let variables = {};
+ const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
+ if (environment) {
+ each(environment.variables, (variable) => {
+ if (variable.name && variable.enabled) {
+ variables[variable.name] = variable.value;
+ }
+ });
+ }
+ return variables;
+};
+
+export const getGlobalEnvironmentVariablesMasked = ({ globalEnvironments, activeGlobalEnvironmentUid }) => {
+ const environment = globalEnvironments?.find(env => env?.uid === activeGlobalEnvironmentUid);
+
+ if (environment && Array.isArray(environment.variables)) {
+ return environment.variables
+ .filter((variable) => variable.name && variable.value && variable.enabled && variable.secret)
+ .map((variable) => variable.name);
+ }
+
+ return [];
+};
+
+
export const getEnvironmentVariables = (collection) => {
let variables = {};
if (collection) {
@@ -655,6 +828,36 @@ export const getEnvironmentVariables = (collection) => {
return variables;
};
+export const getEnvironmentVariablesMasked = (collection) => {
+ // Return an empty array if the collection is invalid or not provided
+ if (!collection || !collection.activeEnvironmentUid) {
+ return [];
+ }
+
+ // Find the active environment in the collection
+ const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
+ if (!environment || !environment.variables) {
+ return [];
+ }
+
+ // Filter the environment variables to get only the masked (secret) ones
+ return environment.variables
+ .filter((variable) => variable.name && variable.value && variable.enabled && variable.secret)
+ .map((variable) => variable.name);
+};
+
+const getPathParams = (item) => {
+ let pathParams = {};
+ if (item && item.request && item.request.params) {
+ item.request.params.forEach((param) => {
+ if (param.type === 'path' && param.name && param.value) {
+ pathParams[param.name] = param.value;
+ }
+ });
+ }
+ return pathParams;
+};
+
export const getTotalRequestCountInCollection = (collection) => {
let count = 0;
each(collection.items, (item) => {
@@ -668,15 +871,54 @@ export const getTotalRequestCountInCollection = (collection) => {
return count;
};
-export const getAllVariables = (collection) => {
- const environmentVariables = getEnvironmentVariables(collection);
+export const getAllVariables = (collection, item) => {
+ if(!collection) return {};
+ const envVariables = getEnvironmentVariables(collection);
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ let { collectionVariables, folderVariables, requestVariables } = mergeVars(collection, requestTreePath);
+ const pathParams = getPathParams(item);
+ const { globalEnvironmentVariables = {} } = collection;
+
+ const { processEnvVariables = {}, runtimeVariables = {} } = collection;
+ const mergedVariables = {
+ ...folderVariables,
+ ...requestVariables,
+ ...runtimeVariables
+ };
+
+ const mergedVariablesGlobal = {
+ ...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...runtimeVariables,
+ }
+
+ const maskedEnvVariables = getEnvironmentVariablesMasked(collection) || [];
+ const maskedGlobalEnvVariables = collection?.globalEnvSecrets || [];
+
+ const filteredMaskedEnvVariables = maskedEnvVariables.filter((key) => !(key in mergedVariables));
+ const filteredMaskedGlobalEnvVariables = maskedGlobalEnvVariables.filter((key) => !(key in mergedVariablesGlobal));
+
+ const uniqueMaskedVariables = [...new Set([...filteredMaskedEnvVariables, ...filteredMaskedGlobalEnvVariables])];
+
+ const oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials })
return {
- ...environmentVariables,
- ...collection.collectionVariables,
+ ...globalEnvironmentVariables,
+ ...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...oauth2CredentialVariables,
+ ...runtimeVariables,
+ pathParams: {
+ ...pathParams
+ },
+ maskedEnvVariables: uniqueMaskedVariables,
process: {
env: {
- ...collection.processEnvVariables
+ ...processEnvVariables
}
}
};
@@ -692,3 +934,154 @@ export const maskInputValue = (value) => {
.map(() => '*')
.join('');
};
+
+const getTreePathFromCollectionToItem = (collection, _item) => {
+ let path = [];
+ let item = findItemInCollection(collection, _item?.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
+ }
+ return path;
+};
+
+const mergeVars = (collection, requestTreePath = []) => {
+ let collectionVariables = {};
+ let folderVariables = {};
+ let requestVariables = {};
+ let collectionRequestVars = get(collection, 'root.request.vars.req', []);
+ collectionRequestVars.forEach((_var) => {
+ if (_var.enabled) {
+ collectionVariables[_var.name] = _var.value;
+ }
+ });
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let vars = get(i, 'root.request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ folderVariables[_var.name] = _var.value;
+ }
+ });
+ } else {
+ let vars = get(i, 'request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ requestVariables[_var.name] = _var.value;
+ }
+ });
+ }
+ }
+ return {
+ collectionVariables,
+ folderVariables,
+ requestVariables
+ };
+};
+
+export const getEnvVars = (environment = {}) => {
+ const variables = environment.variables;
+ if (!variables || !variables.length) {
+ return {
+ __name__: environment.name
+ };
+ }
+
+ const envVars = {};
+ each(variables, (variable) => {
+ if (variable.enabled) {
+ envVars[variable.name] = variable.value;
+ }
+ });
+
+ return {
+ ...envVars,
+ __name__: environment.name
+ };
+};
+
+export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => {
+ let credentialsVariables = {};
+ oauth2Credentials.forEach(({ credentialsId, credentials }) => {
+ if (credentials) {
+ Object.entries(credentials).forEach(([key, value]) => {
+ credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value;
+ });
+ }
+ });
+ return credentialsVariables;
+};
+
+
+// item sequence utils - START
+
+export const resetSequencesInFolder = (folderItems) => {
+ const items = folderItems;
+ const sortedItems = items.sort((a, b) => a.seq - b.seq);
+ return sortedItems.map((item, index) => {
+ item.seq = index + 1;
+ return item;
+ });
+};
+
+export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
+ if (targetItemSequence > sourceItemSequence) {
+ return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
+ }
+ return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
+};
+
+export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
+ if (!isDraggedItem) {
+ return null;
+ }
+ return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
+};
+
+export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
+ const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
+ const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
+ const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
+ const targetSequence = targetItem?.seq;
+ const draggedSequence = draggedItem?.seq;
+ itemsWithFixedSequences?.forEach(item => {
+ const isDraggedItem = item?.uid === draggedItemUid;
+ const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ if (isBetween) {
+ item.seq += targetSequence > draggedSequence ? -1 : 1;
+ }
+ const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
+ if (newSequence !== null) {
+ item.seq = newSequence;
+ }
+ });
+ // only return items that have been reordered
+ return itemsWithFixedSequences.filter(item =>
+ items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
+ );
+};
+
+export const getReorderedItemsInSourceDirectory = ({ items }) => {
+ const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
+ return itemsWithFixedSequences.filter(item =>
+ items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
+ );
+};
+
+export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
+ const { pathname: targetItemPathname } = targetItem;
+ const { filename: draggedItemFilename } = draggedItem;
+ const targetItemDirname = path.dirname(targetItemPathname);
+ const isTargetTheCollection = targetItemPathname === collectionPathname;
+ const isTargetItemAFolder = isItemAFolder(targetItem);
+
+ if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
+ return path.join(targetItemPathname, draggedItemFilename)
+ } else if (dropType === 'adjacent') {
+ return path.join(targetItemDirname, draggedItemFilename)
+ }
+ return null;
+};
+
+// item sequence utils - END
+
diff --git a/packages/bruno-app/src/utils/collections/search.js b/packages/bruno-app/src/utils/collections/search.js
index b420687b7..9c2f187e5 100644
--- a/packages/bruno-app/src/utils/collections/search.js
+++ b/packages/bruno-app/src/utils/collections/search.js
@@ -3,7 +3,7 @@ import filter from 'lodash/filter';
import find from 'lodash/find';
export const doesRequestMatchSearchText = (request, searchText = '') => {
- return request.name.toLowerCase().includes(searchText.toLowerCase());
+ return request?.name?.toLowerCase().includes(searchText.toLowerCase());
};
export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => {
diff --git a/packages/bruno-app/src/utils/common/cache.js b/packages/bruno-app/src/utils/common/cache.js
deleted file mode 100644
index d8cee9e50..000000000
--- a/packages/bruno-app/src/utils/common/cache.js
+++ /dev/null
@@ -1,10 +0,0 @@
-class Cache {
- get(key) {
- return window.localStorage.getItem(key);
- }
- set(key, val) {
- window.localStorage.setItem(key, val);
- }
-}
-
-module.exports = new Cache();
diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js
index a6bf52aed..b1a3d5a8a 100644
--- a/packages/bruno-app/src/utils/common/codemirror.js
+++ b/packages/bruno-app/src/utils/common/codemirror.js
@@ -1,7 +1,7 @@
import get from 'lodash/get';
let CodeMirror;
-const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@@ -12,33 +12,153 @@ const pathFoundInVariables = (path, obj) => {
return value !== undefined;
};
-export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
+/**
+ * Changes the render behaviour for a given CodeMirror editor.
+ * Replaces all **rendered** characters, not the actual value, with the provided character.
+ */
+export class MaskedEditor {
+ /**
+ * @param {import('codemirror').Editor} editor CodeMirror editor instance
+ * @param {string} maskChar Target character being applied to all content
+ */
+ constructor(editor, maskChar) {
+ this.editor = editor;
+ this.maskChar = maskChar;
+ this.enabled = false;
+ }
+
+ /**
+ * Set and apply new masking character
+ */
+ enable = () => {
+ this.enabled = true;
+ this.editor.setValue(this.editor.getValue());
+ this.editor.on('inputRead', this.maskContent);
+ this.update();
+ };
+
+ /** Disables masking of the editor field. */
+ disable = () => {
+ this.enabled = false;
+ this.editor.off('inputRead', this.maskContent);
+ this.editor.setValue(this.editor.getValue());
+ };
+
+ /** Updates the rendered content if enabled. */
+ update = () => {
+ if (this.enabled) this.maskContent();
+ };
+
+ /** Replaces all rendered characters, with the provided character. */
+ maskContent = () => {
+ const content = this.editor.getValue();
+ const lineCount = this.editor.lineCount();
+
+ if (lineCount === 0) return;
+ this.editor.operation(() => {
+ // Clear previous masked text
+ this.editor.getAllMarks().forEach((mark) => mark.clear());
+ // Apply new masked text
+
+ if (content.length <= 500) {
+ for (let i = 0; i < content.length; i++) {
+ if (content[i] !== '\n') {
+ const maskedNode = document.createTextNode(this.maskChar);
+ this.editor.markText(
+ { line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
+ { line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
+ { replacedWith: maskedNode, handleMouseEvents: true }
+ );
+ }
+ }
+ } else {
+ for (let line = 0; line < lineCount; line++) {
+ const lineLength = this.editor.getLine(line).length;
+ const maskedNode = document.createTextNode('*'.repeat(lineLength));
+ this.editor.markText(
+ { line, ch: 0 },
+ { line, ch: lineLength },
+ { replacedWith: maskedNode, handleMouseEvents: false }
+ );
+ }
+ }
+ });
+ };
+}
+
+/**
+ * Defines a custom CodeMirror mode for Bruno variables highlighting.
+ * This function creates a specialized mode that can highlight both Bruno template
+ * variables (in the format {{variable}}) and URL path parameters (in the format /:param).
+ *
+ * @param {Object} _variables - The variables object containing data to validate against
+ * @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json')
+ * @param {boolean} highlightPathParams - Whether to highlight URL path parameters
+ * @param {boolean} highlightVariables - Whether to highlight template variables
+ * @returns {void} - Registers the mode with CodeMirror for later use
+ */
+export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
- let variablesOverlay = {
- token: function (stream, state) {
+ const { pathParams = {}, ...variables } = _variables || {};
+ const variablesOverlay = {
+ token: function (stream) {
if (stream.match('{{', true)) {
let ch;
let word = '';
while ((ch = stream.next()) != null) {
- if (ch == '}' && stream.next() == '}') {
+ if (ch === '}' && stream.peek() === '}') {
stream.eat('}');
- let found = pathFoundInVariables(word, variables);
- if (found) {
- return 'variable-valid random-' + (Math.random() + 1).toString(36).substring(9);
- } else {
- return 'variable-invalid random-' + (Math.random() + 1).toString(36).substring(9);
- }
- // Random classname added so adjacent variables are not rendered in the same SPAN by CodeMirror.
+ const found = pathFoundInVariables(word, variables);
+ const status = found ? 'valid' : 'invalid';
+ const randomClass = `random-${(Math.random() + 1).toString(36).substring(9)}`;
+ return `variable-${status} ${randomClass}`;
}
word += ch;
}
}
- while (stream.next() != null && !stream.match('{{', false)) {}
+ stream.skipTo('{{') || stream.skipToEnd();
return null;
}
};
- return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
+ const urlPathParamsOverlay = {
+ token: function (stream) {
+ if (stream.match('/:', true)) {
+ let ch;
+ let word = '';
+ while ((ch = stream.next()) != null) {
+ if (ch === '/' || ch === '?' || ch === '&' || ch === '=') {
+ stream.backUp(1);
+ const found = pathFoundInVariables(word, pathParams);
+ const status = found ? 'valid' : 'invalid';
+ const randomClass = `random-${(Math.random() + 1).toString(36).substring(9)}`;
+ return `variable-${status} ${randomClass}`;
+ }
+ word += ch;
+ }
+
+ // If we've consumed all characters and the word is not empty, it might be a path parameter at the end of the URL.
+ if (word) {
+ const found = pathFoundInVariables(word, pathParams);
+ const status = found ? 'valid' : 'invalid';
+ const randomClass = `random-${(Math.random() + 1).toString(36).substring(9)}`;
+ return `variable-${status} ${randomClass}`;
+ }
+ }
+ stream.skipTo('/:') || stream.skipToEnd();
+ return null;
+ }
+ };
+
+ let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode);
+
+ if (highlightVariables) {
+ baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay);
+ }
+ if (highlightPathParams) {
+ baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
+ }
+ return baseMode;
});
};
@@ -52,6 +172,8 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
if (contentType.includes('json')) {
return 'application/ld+json';
+ } else if (contentType.includes('image')) {
+ return 'application/image';
} else if (contentType.includes('xml')) {
return 'application/xml';
} else if (contentType.includes('html')) {
@@ -62,8 +184,6 @@ export const getCodeMirrorModeBasedOnContentType = (contentType, body) => {
return 'application/xml';
} else if (contentType.includes('yaml')) {
return 'application/yaml';
- } else if (contentType.includes('image')) {
- return 'application/image';
} else {
return 'application/text';
}
diff --git a/packages/bruno-app/src/utils/common/error.js b/packages/bruno-app/src/utils/common/error.js
index e81e3fadc..c1ae6058c 100644
--- a/packages/bruno-app/src/utils/common/error.js
+++ b/packages/bruno-app/src/utils/common/error.js
@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
return toast.error(errorMsg);
};
+
+export function formatIpcError(error) {
+ if (!(error instanceof Error)) return error;
+ if (!error?.message) return ''; // Avoid returning `null` or `undefined`
+ // https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
+ // There is no other way to get rid of this error prefix as of now.
+ return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
+}
diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js
index f31dd228f..937e1a3b5 100644
--- a/packages/bruno-app/src/utils/common/index.js
+++ b/packages/bruno-app/src/utils/common/index.js
@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
export const convertToCodeMirrorJson = (obj) => {
try {
- return JSON5.stringify(obj).slice(1, -1);
+ return JSON.stringify(obj, null, 2).slice(1, -1);
} catch (e) {
return obj;
}
@@ -83,27 +83,40 @@ export const normalizeFileName = (name) => {
};
export const getContentType = (headers) => {
- const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
- if (headersArray.length > 0) {
- let contentType = headersArray
- .filter((header) => header[0].toLowerCase() === 'content-type')
- .map((header) => {
- return header[1];
- });
- if (contentType && contentType.length) {
- if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
- return 'application/ld+json';
- } else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
- return 'application/xml';
- }
-
- return contentType[0];
- }
+ // Return empty string for invalid headers
+ if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) {
+ return '';
}
- return '';
-};
+ // Get content-type header value
+ const contentTypeHeader = Object.entries(headers)
+ .find(([key]) => key.toLowerCase() === 'content-type');
+
+ const contentType = contentTypeHeader && contentTypeHeader[1];
+
+ // Return empty string if no content-type or not a string
+ if (!contentType || typeof contentType !== 'string') {
+ return '';
+ }
+ // This pattern matches content types like application/json, application/ld+json, text/json, etc.
+ const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/;
+ // This pattern matches content types like image/svg.
+ const SVG_PATTERN = /^image\/svg/i;
+ // This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
+ const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
+
+ if (JSON_PATTERN.test(contentType)) {
+ return 'application/ld+json';
+ } else if (SVG_PATTERN.test(contentType)) {
+ return 'image/svg+xml';
+ } else if (XML_PATTERN.test(contentType)) {
+ return 'application/xml';
+ }
+
+ return contentType;
+}
+
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
@@ -149,10 +162,38 @@ export const relativeDate = (dateString) => {
};
export const humanizeDate = (dateString) => {
+ // See this discussion for why .split is necessary
+ // https://stackoverflow.com/questions/7556591/is-the-javascript-date-object-always-one-day-off
+
+ if (!dateString || typeof dateString !== 'string') {
+ return 'Invalid Date';
+ }
const date = new Date(dateString);
+ if (isNaN(date.getTime())) {
+ return 'Invalid Date';
+ }
+
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
+
+export const generateUidBasedOnHash = (str) => {
+ const hash = simpleHash(str);
+
+ return `${hash}`.padEnd(21, '0');
+};
+
+export const stringifyIfNot = v => typeof v === 'string' ? v : String(v);
+
+export const getEncoding = (headers) => {
+ // Parse the charset from content type: https://stackoverflow.com/a/33192813
+ const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
+ return charsetMatch?.[1];
+}
+
+export const multiLineMsg = (...messages) => {
+ return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/common/index.spec.js b/packages/bruno-app/src/utils/common/index.spec.js
index 3484fac9c..81153674a 100644
--- a/packages/bruno-app/src/utils/common/index.spec.js
+++ b/packages/bruno-app/src/utils/common/index.spec.js
@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
-import { normalizeFileName, startsWith } from './index';
+import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -49,4 +49,103 @@ describe('common utils', () => {
expect(startsWith('foo', 'foo')).toBe(true);
});
});
+
+ describe('humanizeDate', () => {
+ it('should return a date string in the en-US locale', () => {
+ expect(humanizeDate('2024-03-17')).toBe('March 17, 2024');
+ });
+
+ it('should return invalid date if the date is invalid', () => {
+ expect(humanizeDate('9999-99-99')).toBe('Invalid Date');
+ });
+
+ it('should return "Invalid Date" if the date is null', () => {
+ expect(humanizeDate(null)).toBe('Invalid Date');
+ });
+
+ it('should return a humanized date for a valid date in ISO format', () => {
+ expect(humanizeDate('2024-11-28T00:00:00Z')).toBe('November 28, 2024');
+ });
+
+ it('should return "Invalid Date" for a non-date string', () => {
+ expect(humanizeDate('some random text')).toBe('Invalid Date');
+ });
+ });
+
+ describe('relativeDate', () => {
+ it('should return few seconds ago', () => {
+ expect(relativeDate(new Date())).toBe('Few seconds ago');
+ });
+
+ it('should return minutes ago', () => {
+ let date = new Date();
+ date.setMinutes(date.getMinutes() - 30);
+ expect(relativeDate(date)).toBe('30 minutes ago');
+ });
+
+ it('should return hours ago', () => {
+ let date = new Date();
+ date.setHours(date.getHours() - 10);
+ expect(relativeDate(date)).toBe('10 hours ago');
+ });
+
+ it('should return days ago', () => {
+ let date = new Date();
+ date.setDate(date.getDate() - 5);
+ expect(relativeDate(date)).toBe('5 days ago');
+ });
+
+ it('should return weeks ago', () => {
+ let date = new Date();
+ date.setDate(date.getDate() - 8);
+ expect(relativeDate(date)).toBe('1 week ago');
+ });
+
+ it('should return months ago', () => {
+ let date = new Date();
+ date.setDate(date.getDate() - 60);
+ expect(relativeDate(date)).toBe('2 months ago');
+ });
+ });
+
+ describe('getContentType', () => {
+ it('should handle JSON content types correctly', () => {
+ expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json');
+ expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
+ expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json');
+ });
+
+ it('should handle XML content types correctly', () => {
+ expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml');
+ expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml');
+ expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml');
+ });
+
+ it('should handle image content types correctly', () => {
+ expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml');
+ expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml');
+ });
+
+ it('should return original content type when no pattern matches', () => {
+ expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg');
+ expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf');
+ });
+
+ it('should not be case sensitive', () => {
+ expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
+ expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json');
+ });
+
+ it('should handle empty content type', () => {
+ expect(getContentType({ 'content-type': '' })).toBe('');
+ expect(getContentType({ 'content-type': null })).toBe('');
+ expect(getContentType({ 'content-type': undefined })).toBe('');
+ });
+
+ it('should handle empty or invalid inputs', () => {
+ expect(getContentType({})).toBe('');
+ expect(getContentType(null)).toBe('');
+ expect(getContentType(undefined)).toBe('');
+ });
+ });
});
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/path.js b/packages/bruno-app/src/utils/common/path.js
new file mode 100644
index 000000000..f85a15d3c
--- /dev/null
+++ b/packages/bruno-app/src/utils/common/path.js
@@ -0,0 +1,12 @@
+import platform from 'platform';
+import path from 'path';
+
+const isWindowsOS = () => {
+ const os = platform.os;
+ const osFamily = os.family.toLowerCase();
+ return osFamily.includes('windows');
+};
+
+const brunoPath = isWindowsOS() ? path.win32 : path.posix;
+
+export default brunoPath;
diff --git a/packages/bruno-app/src/utils/common/platform.js b/packages/bruno-app/src/utils/common/platform.js
index ddfdb3a1f..dc1d7d984 100644
--- a/packages/bruno-app/src/utils/common/platform.js
+++ b/packages/bruno-app/src/utils/common/platform.js
@@ -1,7 +1,6 @@
import trim from 'lodash/trim';
-import path from 'path';
-import slash from './slash';
import platform from 'platform';
+import path from './path';
export const isElectron = () => {
if (!window) {
@@ -16,21 +15,11 @@ export const resolveRequestFilename = (name) => {
};
export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
- // convert to unix style path
- pathname = slash(pathname);
- rootPath = slash(rootPath);
const relativePath = path.relative(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 isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
@@ -45,8 +34,6 @@ export const isMacOS = () => {
return osFamily.includes('os x');
};
-export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/';
-
export const getAppInstallDate = () => {
let dateString = localStorage.getItem('bruno.installedOn');
diff --git a/packages/bruno-app/src/utils/common/regex.js b/packages/bruno-app/src/utils/common/regex.js
index 53f46741e..a8e4c6eee 100644
--- a/packages/bruno-app/src/utils/common/regex.js
+++ b/packages/bruno-app/src/utils/common/regex.js
@@ -1 +1,56 @@
+const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
+const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
+const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
+const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
+const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
+
export const variableNameRegex = /^[\w-.]*$/;
+
+export const sanitizeName = (name) => {
+ name = name
+ .replace(invalidCharacters, '-') // replace invalid characters with hyphens
+ .replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
+ .replace(/[.\s]+$/, ''); // remove trailing dots and spaces
+ return name;
+};
+
+export const validateName = (name) => {
+ if (!name) return false;
+ if (name.length > 255) return false; // max name length
+
+ if (reservedDeviceNames.test(name)) return false; // windows reserved names
+
+ return (
+ firstCharacter.test(name) &&
+ middleCharacters.test(name) &&
+ lastCharacter.test(name)
+ );
+};
+
+export const validateNameError = (name) => {
+ if (!name) return "Name cannot be empty.";
+
+ if (name.length > 255) {
+ return "Name cannot exceed 255 characters.";
+ }
+
+ if (reservedDeviceNames.test(name)) {
+ return "Name cannot be a reserved device name.";
+ }
+
+ if (!firstCharacter.test(name[0])) {
+ return "Invalid first character.";
+ }
+
+ for (let i = 1; i < name.length - 1; i++) {
+ if (!middleCharacters.test(name[i])) {
+ return `Invalid character '${name[i]}' at position ${i + 1}.`;
+ }
+ }
+
+ if (!lastCharacter.test(name[name.length - 1])) {
+ return "Invalid last character.";
+ }
+
+ return '';
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/common/regex.spec.js b/packages/bruno-app/src/utils/common/regex.spec.js
new file mode 100644
index 000000000..3994a2b2d
--- /dev/null
+++ b/packages/bruno-app/src/utils/common/regex.spec.js
@@ -0,0 +1,166 @@
+const { describe, it, expect } = require('@jest/globals');
+
+import { sanitizeName, validateName } from './regex';
+
+describe('regex validators', () => {
+ describe('sanitize name', () => {
+ it('should remove invalid characters', () => {
+ expect(sanitizeName('hello world')).toBe('hello world');
+ expect(sanitizeName('hello-world')).toBe('hello-world');
+ expect(sanitizeName('hello_world')).toBe('hello_world');
+ expect(sanitizeName('hello_world-')).toBe('hello_world-');
+ expect(sanitizeName('hello_world-123')).toBe('hello_world-123');
+ expect(sanitizeName('hello_world-123!@#$%^&*()')).toBe('hello_world-123!@#$%^&-()');
+ expect(sanitizeName('hello_world?')).toBe('hello_world-');
+ expect(sanitizeName('foo/bar/')).toBe('foo-bar-');
+ expect(sanitizeName('foo\\bar\\')).toBe('foo-bar-');
+ });
+
+ it('should remove leading hyphens', () => {
+ expect(sanitizeName('-foo')).toBe('foo');
+ expect(sanitizeName('---foo')).toBe('foo');
+ expect(sanitizeName('-foo-bar')).toBe('foo-bar');
+ });
+
+ it('should remove trailing periods', () => {
+ expect(sanitizeName('.file')).toBe('.file');
+ expect(sanitizeName('.file.')).toBe('.file');
+ expect(sanitizeName('file.')).toBe('file');
+ expect(sanitizeName('file.name.')).toBe('file.name');
+ expect(sanitizeName('hello world.')).toBe('hello world');
+ });
+
+ it('should handle filenames with only invalid characters', () => {
+ expect(sanitizeName('<>:"/\\|?*')).toBe('');
+ expect(sanitizeName('::::')).toBe('');
+ });
+
+ it('should handle filenames with a mix of valid and invalid characters', () => {
+ expect(sanitizeName('test<>:"/\\|?*')).toBe('test---------');
+ expect(sanitizeName('foo')).toBe('foo-bar-');
+ });
+
+ it('should remove control characters', () => {
+ expect(sanitizeName('foo\x00bar')).toBe('foo-bar');
+ expect(sanitizeName('file\x1Fname')).toBe('file-name');
+ });
+
+ it('should return an empty string if the name is empty or consists only of invalid characters', () => {
+ expect(sanitizeName('')).toBe('');
+ expect(sanitizeName('<>:"/\\|?*')).toBe('');
+ });
+
+ it('should handle filenames with multiple consecutive invalid characters', () => {
+ expect(sanitizeName('foo< {
+ expect(sanitizeName(' ')).toBe('');
+ });
+
+ it('should handle names with leading/trailing spaces', () => {
+ expect(sanitizeName(' foo bar ')).toBe('foo bar');
+ });
+
+ it('should preserve valid non-ASCII characters', () => {
+ expect(sanitizeName('brunó')).toBe('brunó');
+ expect(sanitizeName('文件')).toBe('文件');
+ expect(sanitizeName('brunfais')).toBe('brunfais');
+ expect(sanitizeName('brunai')).toBe('brunai');
+ expect(sanitizeName('brunsборка')).toBe('brunsборка');
+ expect(sanitizeName('brunпривет')).toBe('brunпривет');
+ expect(sanitizeName('🐶')).toBe('🐶');
+ expect(sanitizeName('brunfais🐶')).toBe('brunfais🐶');
+ expect(sanitizeName('file-🐶-bruno')).toBe('file-🐶-bruno');
+ expect(sanitizeName('helló')).toBe('helló');
+ });
+
+ it('should preserve case sensitivity', () => {
+ expect(sanitizeName('FileName')).toBe('FileName');
+ expect(sanitizeName('fileNAME')).toBe('fileNAME');
+ });
+
+ it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
+ expect(sanitizeName('file.name...')).toBe('file.name');
+ expect(sanitizeName('...file')).toBe('...file');
+ expect(sanitizeName('file.name... ')).toBe('file.name');
+ expect(sanitizeName(' ...file')).toBe('...file');
+ expect(sanitizeName(' ...file ')).toBe('...file');
+ expect(sanitizeName(' ...file.... ')).toBe('...file');
+ });
+
+ it('should handle very long filenames', () => {
+ const longName = 'a'.repeat(250) + '.txt';
+ expect(sanitizeName(longName)).toBe(longName);
+ });
+
+ it('should handle names with leading/trailing invalid characters', () => {
+ expect(sanitizeName('-foo/bar-')).toBe('foo-bar-');
+ expect(sanitizeName('/foo\\bar/')).toBe('foo-bar-');
+ });
+
+ it('should handle different language unicode characters', () => {
+ expect(sanitizeName('你好世界!?@#$%^&*()')).toBe('你好世界!-@#$%^&-()');
+ expect(sanitizeName('こんにちは世界!?@#$%^&*()')).toBe('こんにちは世界!-@#$%^&-()');
+ expect(sanitizeName('안녕하세요 세계!?@#$%^&*()')).toBe('안녕하세요 세계!-@#$%^&-()');
+ expect(sanitizeName('مرحبا بالعالم!?@#$%^&*()')).toBe('مرحبا بالعالم!-@#$%^&-()');
+ expect(sanitizeName('Здравствуй мир!?@#$%^&*()')).toBe('Здравствуй мир!-@#$%^&-()');
+ expect(sanitizeName('नमस्ते दुनिया!?@#$%^&*()')).toBe('नमस्ते दुनिया!-@#$%^&-()');
+ expect(sanitizeName('สวัสดีชาวโลก!?@#$%^&*()')).toBe('สวัสดีชาวโลก!-@#$%^&-()');
+ expect(sanitizeName('γειά σου κόσμος!?@#$%^&*()')).toBe('γειά σου κόσμος!-@#$%^&-()');
+ });
+
+ });
+});
+
+describe('sanitizeName and validateName', () => {
+ it('should sanitize and then validate valid names', () => {
+ const validNames = [
+ 'valid_filename.txt',
+ ' valid name ',
+ ' valid-name ',
+ 'valid<>name.txt',
+ 'file/with?invalid*chars'
+ ];
+
+ validNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(true);
+ });
+ });
+
+ it('should sanitize and then validate names with reserved device names', () => {
+ const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT2'];
+
+ reservedNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+
+ it('should sanitize invalid names to empty strings', () => {
+ const invalidNames = [
+ ' <>:"/\\|?* ',
+ ' ... ',
+ ' ',
+ ];
+
+ invalidNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+
+ it('should return false for reserved device names with leading/trailing spaces', () => {
+ const mixedNames = [
+ 'AUX ',
+ ' COM1 '
+ ];
+
+ mixedNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+});
diff --git a/packages/bruno-app/src/utils/common/setupPolyfills.js b/packages/bruno-app/src/utils/common/setupPolyfills.js
new file mode 100644
index 000000000..8d42d17b9
--- /dev/null
+++ b/packages/bruno-app/src/utils/common/setupPolyfills.js
@@ -0,0 +1,24 @@
+export const setupPolyfills = () => {
+ // polyfill required to make react-pdf
+ if (typeof Promise.withResolvers === "undefined") {
+ if (typeof window !== 'undefined') {
+ window.Promise.withResolvers = function () {
+ let resolve, reject
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+ }
+ } else {
+ global.Promise.withResolvers = function () {
+ let resolve, reject
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/common/slash.js b/packages/bruno-app/src/utils/common/slash.js
deleted file mode 100644
index a2b39e94f..000000000
--- a/packages/bruno-app/src/utils/common/slash.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * MIT License
- *
- * Copyright (c) Sindre Sorhus (https://sindresorhus.com)
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-const slash = (path) => {
- const isExtendedLengthPath = /^\\\\\?\\/.test(path);
-
- if (isExtendedLengthPath) {
- return path;
- }
-
- return path.replace(/\\/g, '/');
-};
-
-export default slash;
diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.js b/packages/bruno-app/src/utils/curl/curl-to-json.js
index 82eb0be95..a6239519e 100644
--- a/packages/bruno-app/src/utils/curl/curl-to-json.js
+++ b/packages/bruno-app/src/utils/curl/curl-to-json.js
@@ -36,6 +36,12 @@ function getQueries(request) {
return queries;
}
+/**
+ * Converts request data to a string based on its content type.
+ *
+ * @param {Object} request - The request object containing data and headers.
+ * @returns {Object} An object containing the data string.
+ */
function getDataString(request) {
if (typeof request.data === 'number') {
request.data = request.data.toString();
@@ -44,7 +50,15 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && contentType.includes('application/json')) {
- return { data: request.data.toString() };
+ try {
+ const parsedData = JSON.parse(request.data);
+ return { data: JSON.stringify(parsedData) };
+ } catch (error) {
+ console.error('Failed to parse JSON data:', error);
+ return { data: request.data.toString() };
+ }
+ } else if (contentType && (contentType.includes('application/xml') || contentType.includes('text/plain'))) {
+ return { data: request.data };
}
const parsedQueryString = querystring.parse(request.data, { sort: false });
@@ -85,9 +99,30 @@ function getMultipleDataString(request, parsedQueryString) {
function getFilesString(request) {
const data = {};
- data.files = {};
data.data = {};
+ if (request.isDataBinary) {
+ let filePath = '';
+
+ if (request.data.startsWith('@')) {
+ filePath = request.data.slice(1);
+ } else {
+ filePath = request.data;
+ }
+
+ data.data = [
+ {
+ filePath: repr(filePath),
+ contentType: request.headers['Content-Type'],
+ selected: true,
+ }
+ ];
+
+ return data;
+ }
+
+ data.files = {};
+
for (const multipartKey in request.multipartUploads) {
const multipartValue = request.multipartUploads[multipartKey];
if (multipartValue.startsWith('@')) {
@@ -123,9 +158,10 @@ const curlToJson = (curlCommand) => {
request.urlWithoutQuery = 'http://' + request.urlWithoutQuery;
}
- requestJson.url = request.urlWithoutQuery.replace(/\/$/, '');
+ requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url;
requestJson.method = request.method;
+ requestJson.isDataBinary = request.isDataBinary;
if (request.cookies) {
const cookies = {};
@@ -147,12 +183,16 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
- }
-
- if (typeof request.data === 'string' || typeof request.data === 'number') {
- Object.assign(requestJson, getDataString(request));
} else if (request.multipartUploads) {
+ requestJson.data = request.multipartUploads;
+ if (!requestJson.headers) {
+ requestJson.headers = {};
+ }
+ requestJson.headers['Content-Type'] = 'multipart/form-data';
+ } else if (request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
+ } else if (typeof request.data === 'string' || typeof request.data === 'number') {
+ Object.assign(requestJson, getDataString(request));
}
if (request.insecure) {
@@ -160,14 +200,15 @@ const curlToJson = (curlCommand) => {
}
if (request.auth) {
- const splitAuth = request.auth.split(':');
- const user = splitAuth[0] || '';
- const password = splitAuth[1] || '';
-
- requestJson.auth = {
- user: repr(user),
- password: repr(password)
- };
+ if (request.auth.mode === 'basic') {
+ requestJson.auth = {
+ mode: 'basic',
+ basic: {
+ username: repr(request.auth.basic?.username),
+ password: repr(request.auth.basic?.password)
+ }
+ };
+ }
}
return Object.keys(requestJson).length ? requestJson : {};
diff --git a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js
index 2704bd4c5..991150c57 100644
--- a/packages/bruno-app/src/utils/curl/curl-to-json.spec.js
+++ b/packages/bruno-app/src/utils/curl/curl-to-json.spec.js
@@ -75,4 +75,49 @@ describe('curlToJson', () => {
}
});
});
+
+ it('should return and parse a simple curl command with a trailing slash', () => {
+ const curlCommand = 'curl https://www.usebruno.com/';
+ const result = curlToJson(curlCommand);
+
+ expect(result).toEqual({
+ url: 'https://www.usebruno.com/',
+ raw_url: 'https://www.usebruno.com/',
+ method: 'get'
+ });
+ });
+
+ it('should return a parse a curl with a post body with binary file type', () => {
+ const curlCommand = `curl 'https://www.usebruno.com'
+ -H 'Accept: application/json, text/plain, */*'
+ -H 'Accept-Language: en-US,en;q=0.9,hi;q=0.8'
+ -H 'Content-Type: application/json;charset=utf-8'
+ -H 'Origin: https://www.usebruno.com'
+ -H 'Referer: https://www.usebruno.com/'
+ --data-binary '@/path/to/file'
+ `;
+
+ const result = curlToJson(curlCommand);
+
+ expect(result).toEqual({
+ url: 'https://www.usebruno.com',
+ raw_url: 'https://www.usebruno.com',
+ method: 'post',
+ headers: {
+ Accept: 'application/json, text/plain, */*',
+ 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
+ 'Content-Type': 'application/json;charset=utf-8',
+ Origin: 'https://www.usebruno.com',
+ Referer: 'https://www.usebruno.com/'
+ },
+ isDataBinary: true,
+ data: [
+ {
+ filePath: '/path/to/file',
+ contentType: 'application/json;charset=utf-8',
+ selected: true
+ }
+ ]
+ });
+ });
});
diff --git a/packages/bruno-app/src/utils/curl/index.js b/packages/bruno-app/src/utils/curl/index.js
index 97bfbd966..ad4f1edf6 100644
--- a/packages/bruno-app/src/utils/curl/index.js
+++ b/packages/bruno-app/src/utils/curl/index.js
@@ -2,7 +2,7 @@ import { forOwn } from 'lodash';
import { convertToCodeMirrorJson } from 'utils/common';
import curlToJson from './curl-to-json';
-export const getRequestFromCurlCommand = (curlCommand) => {
+export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
const formData = [];
forOwn(parsedBody, (value, key) => {
@@ -12,6 +12,22 @@ export const getRequestFromCurlCommand = (curlCommand) => {
return formData;
};
+ const parseGraphQL = (text) => {
+ try {
+ const graphql = JSON.parse(text);
+
+ return {
+ query: graphql.query,
+ variables: JSON.stringify(graphql.variables, null, 2)
+ };
+ } catch (e) {
+ return {
+ query: '',
+ variables: ''
+ };
+ }
+ };
+
try {
if (!curlCommand || typeof curlCommand !== 'string' || curlCommand.length === 0) {
return null;
@@ -24,6 +40,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
Object.keys(parsedHeaders).map((key) => ({ name: key, value: parsedHeaders[key], enabled: true }));
const contentType = headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value;
+ const parsedBody = request.data;
+
const body = {
mode: 'none',
json: null,
@@ -31,14 +49,22 @@ export const getRequestFromCurlCommand = (curlCommand) => {
xml: null,
sparql: null,
multipartForm: null,
- formUrlEncoded: null
+ formUrlEncoded: null,
+ graphql: null,
+ file: null
};
- const parsedBody = request.data;
+
if (parsedBody && contentType && typeof contentType === 'string') {
- if (contentType.includes('application/json')) {
+ if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
+ body.mode = 'graphql';
+ body.graphql = parseGraphQL(parsedBody);
+ } else if (requestType === 'http-request' && request.isDataBinary) {
+ body.mode = 'file';
+ body.file = parsedBody;
+ }else if (contentType.includes('application/json')) {
body.mode = 'json';
body.json = convertToCodeMirrorJson(parsedBody);
- } else if (contentType.includes('text/xml')) {
+ } else if (contentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -56,7 +82,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
url: request.url,
method: request.method,
body,
- headers: headers
+ headers: headers,
+ auth: request.auth
};
} catch (error) {
console.error(error);
diff --git a/packages/bruno-app/src/utils/curl/parse-curl.js b/packages/bruno-app/src/utils/curl/parse-curl.js
index 77426da83..afdc10395 100644
--- a/packages/bruno-app/src/utils/curl/parse-curl.js
+++ b/packages/bruno-app/src/utils/curl/parse-curl.js
@@ -36,7 +36,9 @@ const parseCurlCommand = (curlCommand) => {
boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'],
alias: {
H: 'header',
- A: 'user-agent'
+ A: 'user-agent',
+ u: 'user',
+ F: 'form'
}
});
@@ -72,11 +74,10 @@ const parseCurlCommand = (curlCommand) => {
parsedArguments.header.forEach((header) => {
if (header.indexOf('Cookie') !== -1) {
cookieString = header;
- } else {
- const components = header.split(/:(.*)/);
- if (components[1]) {
- headers[components[0]] = components[1].trim();
- }
+ }
+ const components = header.split(/:(.*)/);
+ if (components[1]) {
+ headers[components[0]] = components[1].trim();
}
});
}
@@ -95,17 +96,31 @@ const parseCurlCommand = (curlCommand) => {
cookieString = parsedArguments.cookie;
}
let multipartUploads;
- if (parsedArguments.F) {
- multipartUploads = {};
- if (!Array.isArray(parsedArguments.F)) {
- parsedArguments.F = [parsedArguments.F];
- }
- parsedArguments.F.forEach((multipartArgument) => {
- // input looks like key=value. value could be json or a file path prepended with an @
- const splitArguments = multipartArgument.split('=', 2);
- const key = splitArguments[0];
- const value = splitArguments[1];
- multipartUploads[key] = value;
+ // Handle multipart form data specified via -F or --form flags
+ // Example: curl -F 'id=123' -F 'file=@/path/to/file.txt'
+ if (parsedArguments.F || parsedArguments.form) {
+ multipartUploads = [];
+ const formArgs = parsedArguments.F || parsedArguments.form;
+ const formArray = Array.isArray(formArgs) ? formArgs : [formArgs];
+
+ formArray.forEach((multipartArgument) => {
+ // Parse each form field using regex:
+ // - Group 1: Field name before =
+ // - Group 2: Value in quotes after = (for text fields)
+ // - Group 3: Value after @ (for file fields)
+ const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/);
+ if (match) {
+ const key = match[1];
+ const value = match[2] || match[3] || '';
+ const isFile = multipartArgument.includes('@');
+
+ multipartUploads.push({
+ name: key,
+ value: value,
+ type: isFile ? 'file' : 'text',
+ enabled: true
+ });
+ }
});
}
if (cookieString) {
@@ -119,15 +134,16 @@ const parseCurlCommand = (curlCommand) => {
cookies = cookie.parse(cookieString.replace(/^Cookie: /gi, ''), cookieParseOptions);
}
let method;
- if (parsedArguments.X === 'POST') {
+ let parsedMethodArgument = parsedArguments.X || parsedArguments.request || parsedArguments.T;
+ if (parsedMethodArgument === 'POST') {
method = 'post';
- } else if (parsedArguments.X === 'PUT' || parsedArguments.T) {
+ } else if (parsedMethodArgument === 'PUT') {
method = 'put';
- } else if (parsedArguments.X === 'PATCH') {
+ } else if (parsedMethodArgument === 'PATCH') {
method = 'patch';
- } else if (parsedArguments.X === 'DELETE') {
+ } else if (parsedMethodArgument === 'DELETE') {
method = 'delete';
- } else if (parsedArguments.X === 'OPTIONS') {
+ } else if (parsedMethodArgument === 'OPTIONS') {
method = 'options';
} else if (
(parsedArguments.d ||
@@ -187,10 +203,21 @@ const parseCurlCommand = (curlCommand) => {
}
urlObject.search = null; // Clean out the search/query portion.
+
+ let urlWithoutQuery = URL.format(urlObject);
+ let urlHost = urlObject?.host;
+ if (!url?.includes(`${urlHost}/`)) {
+ if (urlWithoutQuery && urlHost) {
+ const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost);
+ urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1);
+ }
+ }
+
const request = {
- url: url,
- urlWithoutQuery: URL.format(urlObject)
+ url,
+ urlWithoutQuery
};
+
if (compressed) {
request.compressed = true;
}
@@ -226,12 +253,19 @@ const parseCurlCommand = (curlCommand) => {
request.data = parsedArguments['data-urlencode'];
}
- if (parsedArguments.u) {
- request.auth = parsedArguments.u;
- }
- if (parsedArguments.user) {
- request.auth = parsedArguments.user;
+ if (parsedArguments.user && typeof parsedArguments.user === 'string') {
+ const basicAuth = parsedArguments.user.split(':')
+ const username = basicAuth[0] || ''
+ const password = basicAuth[1] || ''
+ request.auth = {
+ mode: 'basic',
+ basic: {
+ username,
+ password
+ }
+ }
}
+
if (Array.isArray(request.data)) {
request.dataArray = request.data;
request.data = request.data.join('&');
diff --git a/packages/bruno-app/src/utils/curl/parse-curl.spec.js b/packages/bruno-app/src/utils/curl/parse-curl.spec.js
new file mode 100644
index 000000000..13b77645c
--- /dev/null
+++ b/packages/bruno-app/src/utils/curl/parse-curl.spec.js
@@ -0,0 +1,145 @@
+const { describe, it, expect } = require('@jest/globals');
+import parseCurlCommand from './parse-curl';
+
+describe('parseCurlCommand', () => {
+ describe('basic functionality', () => {
+ it('should handle basic GET request', () => {
+ const result = parseCurlCommand('curl https://api.example.com/users');
+ expect(result).toEqual({
+ url: 'https://api.example.com/users',
+ urlWithoutQuery: 'https://api.example.com/users',
+ method: 'get'
+ });
+ });
+
+ it('should parse explicit POST method', () => {
+ const result = parseCurlCommand('curl -X POST https://api.example.com/users');
+ expect(result).toEqual({
+ url: 'https://api.example.com/users',
+ urlWithoutQuery: 'https://api.example.com/users',
+ method: 'post'
+ });
+ });
+ });
+
+ describe('headers handling', () => {
+ it('should parse multiple headers', () => {
+ const result = parseCurlCommand(
+ `curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
+ );
+ expect(result).toEqual({
+ url: 'https://api.example.com',
+ urlWithoutQuery: 'https://api.example.com',
+ method: 'get',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer token'
+ }
+ });
+ });
+
+ it('should parse user-agent', () => {
+ const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
+ expect(result).toEqual({
+ url: 'https://api.example.com',
+ urlWithoutQuery: 'https://api.example.com',
+ method: 'get',
+ headers: {
+ 'User-Agent': 'Custom Agent'
+ }
+ });
+ });
+ });
+
+ describe('auth handling', () => {
+ it('should parse basic auth', () => {
+ const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
+ expect(result).toEqual({
+ url: 'https://api.example.com',
+ urlWithoutQuery: 'https://api.example.com',
+ method: 'get',
+ auth: {
+ mode: 'basic',
+ basic: {
+ username: 'user',
+ password: 'pass'
+ }
+ }
+ });
+ });
+ });
+
+ describe('data handling', () => {
+ it('should parse POST data', () => {
+ const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
+ expect(result).toEqual({
+ url: 'https://api.example.com',
+ urlWithoutQuery: 'https://api.example.com',
+ method: 'post',
+ data: 'foo=bar&baz=qux'
+ });
+ });
+
+ it('should handle data-binary', () => {
+ const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
+ expect(result).toEqual({
+ url: 'https://api.example.com',
+ urlWithoutQuery: 'https://api.example.com',
+ method: 'post',
+ data: '@file.json',
+ isDataBinary: true
+ });
+ });
+ });
+
+ describe('form data handling', () => {
+ it('should parse complex form data with multiple fields and file upload', () => {
+ const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
+ --form 'id="1"' \
+ --form 'documentid="ADMINN_ID"' \
+ --form 'appoinID="12376"' \
+ --form 'autoclose="false"' \
+ --form 'fileData=@"/path/to/file"'`;
+
+ const result = parseCurlCommand(curlCommand);
+
+ expect(result).toEqual({
+ url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
+ urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
+ method: 'post',
+ multipartUploads: [
+ {
+ name: 'id',
+ value: '1',
+ type: 'text',
+ enabled: true
+ },
+ {
+ name: 'documentid',
+ value: 'ADMINN_ID',
+ type: 'text',
+ enabled: true
+ },
+ {
+ name: 'appoinID',
+ value: '12376',
+ type: 'text',
+ enabled: true
+ },
+ {
+ name: 'autoclose',
+ value: 'false',
+ type: 'text',
+ enabled: true
+ },
+ {
+ name: 'fileData',
+ value: '/path/to/file',
+ type: 'file',
+ enabled: true
+ }
+ ]
+ });
+ });
+ });
+});
diff --git a/packages/bruno-app/src/utils/exporters/postman-collection.js b/packages/bruno-app/src/utils/exporters/postman-collection.js
index 309a77d05..65fc7e1ca 100644
--- a/packages/bruno-app/src/utils/exporters/postman-collection.js
+++ b/packages/bruno-app/src/utils/exporters/postman-collection.js
@@ -1,210 +1,9 @@
-import map from 'lodash/map';
import * as FileSaver from 'file-saver';
-import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from 'utils/collections/export';
+import { brunoToPostman } from '@usebruno/converters';
export const exportCollection = (collection) => {
- delete collection.uid;
- delete collection.processEnvVariables;
- deleteUidsInItems(collection.items);
- deleteUidsInEnvs(collection.environments);
- deleteSecretsInEnvs(collection.environments);
- const generateInfoSection = () => {
- return {
- name: collection.name,
- schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
- };
- };
-
- const generateCollectionVars = (collection) => {
- const pattern = /{{[^{}]+}}/g;
- let listOfVars = [];
-
- const findOccurrences = (obj, results) => {
- if (typeof obj === 'object') {
- if (Array.isArray(obj)) {
- obj.forEach((item) => findOccurrences(item, results));
- } else {
- for (const key in obj) {
- findOccurrences(obj[key], results);
- }
- }
- } else if (typeof obj === 'string') {
- obj.replace(pattern, (match) => {
- results.push(match.replace(/{{|}}/g, ''));
- });
- }
- };
-
- findOccurrences(collection, listOfVars);
-
- const finalArrayOfVars = [...new Set(listOfVars)];
-
- return finalArrayOfVars.map((variable) => ({
- key: variable,
- value: '',
- type: 'default'
- }));
- };
-
- const generateEventSection = (item) => {
- const eventArray = [];
- if (item?.request?.tests?.length) {
- eventArray.push({
- listen: 'test',
- script: {
- exec: item.request.tests.split('\n')
- // type: 'text/javascript'
- }
- });
- }
- if (item?.request?.script?.req) {
- eventArray.push({
- listen: 'prerequest',
- script: {
- exec: item.request.script.req.split('\n')
- // type: 'text/javascript'
- }
- });
- }
- return eventArray;
- };
-
- const generateHeaders = (headersArray) => {
- return map(headersArray, (item) => {
- return {
- key: item.name,
- value: item.value,
- disabled: !item.enabled,
- type: 'default'
- };
- });
- };
-
- const generateBody = (body) => {
- switch (body.mode) {
- case 'formUrlEncoded':
- return {
- mode: 'urlencoded',
- urlencoded: map(body.formUrlEncoded, (bodyItem) => {
- return {
- key: bodyItem.name,
- value: bodyItem.value,
- disabled: !bodyItem.enabled,
- type: 'default'
- };
- })
- };
- case 'multipartForm':
- return {
- mode: 'formdata',
- formdata: map(body.multipartForm, (bodyItem) => {
- return {
- key: bodyItem.name,
- value: bodyItem.value,
- disabled: !bodyItem.enabled,
- type: 'default'
- };
- })
- };
- case 'json':
- return {
- mode: 'raw',
- raw: body.json,
- options: {
- raw: {
- language: 'json'
- }
- }
- };
- case 'xml':
- return {
- mode: 'raw',
- raw: body.xml,
- options: {
- raw: {
- language: 'xml'
- }
- }
- };
- case 'text':
- return {
- mode: 'raw',
- raw: body.text,
- options: {
- raw: {
- language: 'text'
- }
- }
- };
- }
- };
-
- const generateAuth = (itemAuth) => {
- switch (itemAuth) {
- case 'bearer':
- return {
- type: 'bearer',
- bearer: {
- key: 'token',
- value: itemAuth.bearer.token,
- type: 'string'
- }
- };
- case 'basic': {
- return {
- type: 'basic',
- basic: [
- {
- key: 'password',
- value: itemAuth.basic.password,
- type: 'string'
- },
- {
- key: 'username',
- value: itemAuth.basic.username,
- type: 'string'
- }
- ]
- };
- }
- }
- };
-
- const generateRequestSection = (itemRequest) => {
- const requestObject = {
- method: itemRequest.method,
- header: generateHeaders(itemRequest.headers),
- url: itemRequest.url,
- auth: generateAuth(itemRequest.auth)
- };
-
- if (itemRequest.body.mode != 'none') {
- requestObject.body = generateBody(itemRequest.body);
- }
- return requestObject;
- };
-
- const generateItemSection = (itemsArray) => {
- return map(itemsArray, (item) => {
- if (item.type === 'folder') {
- return {
- name: item.name,
- item: item.items.length ? generateItemSection(item.items) : []
- };
- } else {
- return {
- name: item.name,
- event: generateEventSection(item),
- request: generateRequestSection(item.request)
- };
- }
- });
- };
- const collectionToExport = {};
- collectionToExport.info = generateInfoSection();
- collectionToExport.item = generateItemSection(collection.items);
- collectionToExport.variable = generateCollectionVars(collection);
+ const collectionToExport = brunoToPostman(collection);
const fileName = `${collection.name}.json`;
const fileBlob = new Blob([JSON.stringify(collectionToExport, null, 2)], { type: 'application/json' });
diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js
index f1e17ac00..d4903629c 100644
--- a/packages/bruno-app/src/utils/importers/common.js
+++ b/packages/bruno-app/src/utils/importers/common.js
@@ -2,7 +2,7 @@ import each from 'lodash/each';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import { uuid, normalizeFileName } from 'utils/common';
+import { uuid } from 'utils/common';
import { isItemARequest } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';
@@ -29,13 +29,13 @@ export const updateUidsInCollection = (_collection) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
- each(get(item, 'request.query'), (param) => (param.uid = uuid()));
each(get(item, 'request.params'), (param) => (param.uid = uuid()));
each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
+ each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
if (item.items && item.items.length) {
updateItemUids(item.items);
@@ -62,12 +62,16 @@ export const updateUidsInCollection = (_collection) => {
export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => {
each(items, (item) => {
- item.name = normalizeFileName(item.name);
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;
+
if (item.request.query) {
- item.request.params = item.request.query;
+ item.request.params = item.request.query.map((queryItem) => ({
+ ...queryItem,
+ type: 'query',
+ uid: queryItem.uid || uuid()
+ }));
}
delete item.request.query;
@@ -75,9 +79,9 @@ export const transformItemsInCollection = (collection) => {
// from 5 feb 2024, multipartFormData needs to have a type
// this was introduced when we added support for file uploads
// below logic is to make older collection exports backward compatible
- let multipartFormData = _.get(item, 'request.body.multipartForm');
+ let multipartFormData = get(item, 'request.body.multipartForm');
if (multipartFormData) {
- _.each(multipartFormData, (form) => {
+ each(multipartFormData, (form) => {
if (!form.type) {
form.type = 'text';
}
diff --git a/packages/bruno-app/src/utils/importers/insomnia-collection.js b/packages/bruno-app/src/utils/importers/insomnia-collection.js
index 0a38a85e5..c81efaee7 100644
--- a/packages/bruno-app/src/utils/importers/insomnia-collection.js
+++ b/packages/bruno-app/src/utils/importers/insomnia-collection.js
@@ -1,10 +1,7 @@
import jsyaml from 'js-yaml';
-import each from 'lodash/each';
-import get from 'lodash/get';
import fileDialog from 'file-dialog';
-import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
-import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
+import { insomniaToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -17,7 +14,7 @@ const readFile = (files) => {
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
- const parsedData = jsyaml.load(e.target.result);
+ const parsedData = jsyaml.load(e.target.result, { schema: jsyaml.CORE_SCHEMA });
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
@@ -30,213 +27,11 @@ const readFile = (files) => {
});
};
-const parseGraphQL = (text) => {
- try {
- const graphql = JSON.parse(text);
-
- return {
- query: graphql.query,
- variables: JSON.stringify(graphql.variables, null, 2)
- };
- } catch (e) {
- return {
- query: '',
- variables: ''
- };
- }
-};
-
-const addSuffixToDuplicateName = (item, index, allItems) => {
- // Check if the request name already exist and if so add a number suffix
- const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
- if (otherItem.name === item.name && otherIndex < index) {
- nameSuffix++;
- }
- return nameSuffix;
- }, 0);
- return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
-};
-
-const regexVariable = new RegExp('{{.*?}}', 'g');
-
-const normalizeVariables = (value) => {
- const variables = value.match(regexVariable) || [];
- each(variables, (variable) => {
- value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
- });
- return value;
-};
-
-const transformInsomniaRequestItem = (request, index, allRequests) => {
- const name = addSuffixToDuplicateName(request, index, allRequests);
-
- const brunoRequestItem = {
- uid: uuid(),
- name,
- type: 'http-request',
- request: {
- url: request.url,
- method: request.method,
- auth: {
- mode: 'none',
- basic: null,
- bearer: null,
- digest: null
- },
- headers: [],
- params: [],
- body: {
- mode: 'none',
- json: null,
- text: null,
- xml: null,
- formUrlEncoded: [],
- multipartForm: []
- }
- }
- };
-
- each(request.headers, (header) => {
- brunoRequestItem.request.headers.push({
- uid: uuid(),
- name: header.name,
- value: header.value,
- description: header.description,
- enabled: !header.disabled
- });
- });
-
- each(request.parameters, (param) => {
- brunoRequestItem.request.params.push({
- uid: uuid(),
- name: param.name,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
-
- const authType = get(request, 'authentication.type', '');
-
- if (authType === 'basic') {
- brunoRequestItem.request.auth.mode = 'basic';
- brunoRequestItem.request.auth.basic = {
- username: normalizeVariables(get(request, 'authentication.username', '')),
- password: normalizeVariables(get(request, 'authentication.password', ''))
- };
- } else if (authType === 'bearer') {
- brunoRequestItem.request.auth.mode = 'bearer';
- brunoRequestItem.request.auth.bearer = {
- token: normalizeVariables(get(request, 'authentication.token', ''))
- };
- }
-
- const mimeType = get(request, 'body.mimeType', '').split(';')[0];
-
- if (mimeType === 'application/json') {
- brunoRequestItem.request.body.mode = 'json';
- brunoRequestItem.request.body.json = request.body.text;
- } else if (mimeType === 'application/x-www-form-urlencoded') {
- brunoRequestItem.request.body.mode = 'formUrlEncoded';
- each(request.body.params, (param) => {
- brunoRequestItem.request.body.formUrlEncoded.push({
- uid: uuid(),
- name: param.name,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
- } else if (mimeType === 'multipart/form-data') {
- brunoRequestItem.request.body.mode = 'multipartForm';
- each(request.body.params, (param) => {
- brunoRequestItem.request.body.multipartForm.push({
- uid: uuid(),
- type: 'text',
- name: param.name,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
- } else if (mimeType === 'text/plain') {
- brunoRequestItem.request.body.mode = 'text';
- brunoRequestItem.request.body.text = request.body.text;
- } else if (mimeType === 'text/xml') {
- brunoRequestItem.request.body.mode = 'xml';
- brunoRequestItem.request.body.xml = request.body.text;
- } else if (mimeType === 'application/graphql') {
- brunoRequestItem.type = 'graphql-request';
- brunoRequestItem.request.body.mode = 'graphql';
- brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
- }
-
- return brunoRequestItem;
-};
-
-const parseInsomniaCollection = (data) => {
- const brunoCollection = {
- name: '',
- uid: uuid(),
- version: '1',
- items: [],
- environments: []
- };
-
- return new Promise((resolve, reject) => {
- try {
- const insomniaExport = data;
- const insomniaResources = get(insomniaExport, 'resources', []);
- const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
-
- if (!insomniaCollection) {
- reject(new BrunoError('Collection not found inside Insomnia export'));
- }
-
- brunoCollection.name = insomniaCollection.name;
-
- const requestsAndFolders =
- insomniaResources.filter((resource) => resource._type === 'request' || resource._type === 'request_group') ||
- [];
-
- function createFolderStructure(resources, parentId = null) {
- const requestGroups =
- resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
- const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
-
- const folders = requestGroups.map((folder, index, allFolder) => {
- const name = addSuffixToDuplicateName(folder, index, allFolder);
- const requests = resources.filter(
- (resource) => resource._type === 'request' && resource.parentId === folder._id
- );
-
- return {
- uid: uuid(),
- name,
- type: 'folder',
- items: createFolderStructure(resources, folder._id).concat(requests.map(transformInsomniaRequestItem))
- };
- });
-
- return folders.concat(requests.map(transformInsomniaRequestItem));
- }
-
- (brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id)),
- resolve(brunoCollection);
- } catch (err) {
- reject(new BrunoError('An error occurred while parsing the Insomnia collection'));
- }
- });
-};
-
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
- .then(parseInsomniaCollection)
- .then(transformItemsInCollection)
- .then(hydrateSeqInCollection)
- .then(validateSchema)
+ .then((collection) => insomniaToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
diff --git a/packages/bruno-app/src/utils/importers/openapi-collection.js b/packages/bruno-app/src/utils/importers/openapi-collection.js
index 1900f6e47..70cbe918c 100644
--- a/packages/bruno-app/src/utils/importers/openapi-collection.js
+++ b/packages/bruno-app/src/utils/importers/openapi-collection.js
@@ -1,10 +1,7 @@
import jsyaml from 'js-yaml';
-import each from 'lodash/each';
-import get from 'lodash/get';
import fileDialog from 'file-dialog';
-import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
-import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
+import { openApiToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -30,354 +27,11 @@ const readFile = (files) => {
});
};
-const ensureUrl = (url) => {
- let protUrl = url.startsWith('http') ? url : `http://${url}`;
- // replace any double or triple slashes
- return protUrl.replace(/([^:]\/)\/+/g, '$1');
-};
-
-const buildEmptyJsonBody = (bodySchema) => {
- let _jsonBody = {};
- each(bodySchema.properties || {}, (prop, name) => {
- if (prop.type === 'object') {
- _jsonBody[name] = buildEmptyJsonBody(prop);
- // handle arrays
- } else if (prop.type === 'array') {
- _jsonBody[name] = [];
- } else {
- _jsonBody[name] = '';
- }
- });
- return _jsonBody;
-};
-
-const transformOpenapiRequestItem = (request) => {
- let _operationObject = request.operationObject;
-
- let operationName = _operationObject.summary || _operationObject.operationId || _operationObject.description;
- if (!operationName) {
- operationName = `${request.method} ${request.path}`;
- }
-
- const brunoRequestItem = {
- uid: uuid(),
- name: operationName,
- type: 'http-request',
- request: {
- url: ensureUrl(request.global.server + '/' + request.path),
- method: request.method.toUpperCase(),
- auth: {
- mode: 'none',
- basic: null,
- bearer: null,
- digest: null
- },
- headers: [],
- params: [],
- body: {
- mode: 'none',
- json: null,
- text: null,
- xml: null,
- formUrlEncoded: [],
- multipartForm: []
- }
- }
- };
-
- each(_operationObject.parameters || [], (param) => {
- if (param.in === 'query') {
- brunoRequestItem.request.params.push({
- uid: uuid(),
- name: param.name,
- value: '',
- description: param.description || '',
- enabled: param.required
- });
- } else if (param.in === 'header') {
- brunoRequestItem.request.headers.push({
- uid: uuid(),
- name: param.name,
- value: '',
- description: param.description || '',
- enabled: param.required
- });
- }
- });
-
- let auth;
- // allow operation override
- if (_operationObject.security && _operationObject.security.length > 0) {
- let schemeName = Object.keys(_operationObject.security[0])[0];
- auth = request.global.security.getScheme(schemeName);
- } else if (request.global.security.supported.length > 0) {
- auth = request.global.security.supported[0];
- }
-
- if (auth) {
- if (auth.type === 'http' && auth.scheme === 'basic') {
- brunoRequestItem.request.auth.mode = 'basic';
- brunoRequestItem.request.auth.basic = {
- username: '{{username}}',
- password: '{{password}}'
- };
- } else if (auth.type === 'http' && auth.scheme === 'bearer') {
- brunoRequestItem.request.auth.mode = 'bearer';
- brunoRequestItem.request.auth.bearer = {
- token: '{{token}}'
- };
- } else if (auth.type === 'apiKey' && auth.in === 'header') {
- brunoRequestItem.request.headers.push({
- uid: uuid(),
- name: auth.name,
- value: '{{apiKey}}',
- description: 'Authentication header',
- enabled: true
- });
- }
- }
-
- // TODO: handle allOf/anyOf/oneOf
- if (_operationObject.requestBody) {
- let content = get(_operationObject, 'requestBody.content', {});
- let mimeType = Object.keys(content)[0];
- let body = content[mimeType] || {};
- let bodySchema = body.schema;
- if (mimeType === 'application/json') {
- brunoRequestItem.request.body.mode = 'json';
- if (bodySchema && bodySchema.type === 'object') {
- let _jsonBody = buildEmptyJsonBody(bodySchema);
- brunoRequestItem.request.body.json = JSON.stringify(_jsonBody, null, 2);
- }
- } else if (mimeType === 'application/x-www-form-urlencoded') {
- brunoRequestItem.request.body.mode = 'formUrlEncoded';
- if (bodySchema && bodySchema.type === 'object') {
- each(bodySchema.properties || {}, (prop, name) => {
- brunoRequestItem.request.body.formUrlEncoded.push({
- uid: uuid(),
- name: name,
- value: '',
- description: prop.description || '',
- enabled: true
- });
- });
- }
- } else if (mimeType === 'multipart/form-data') {
- brunoRequestItem.request.body.mode = 'multipartForm';
- if (bodySchema && bodySchema.type === 'object') {
- each(bodySchema.properties || {}, (prop, name) => {
- brunoRequestItem.request.body.multipartForm.push({
- uid: uuid(),
- type: 'text',
- name: name,
- value: '',
- description: prop.description || '',
- enabled: true
- });
- });
- }
- } else if (mimeType === 'text/plain') {
- brunoRequestItem.request.body.mode = 'text';
- brunoRequestItem.request.body.text = '';
- } else if (mimeType === 'text/xml') {
- brunoRequestItem.request.body.mode = 'xml';
- brunoRequestItem.request.body.xml = '';
- }
- }
-
- return brunoRequestItem;
-};
-
-const resolveRefs = (spec, components = spec.components, visitedItems = new Set()) => {
- if (!spec || typeof spec !== 'object') {
- return spec;
- }
-
- if (Array.isArray(spec)) {
- return spec.map((item) => resolveRefs(item, components, visitedItems));
- }
-
- if ('$ref' in spec) {
- const refPath = spec.$ref;
-
- if (visitedItems.has(refPath)) {
- return spec;
- } else {
- visitedItems.add(refPath);
- }
-
- if (refPath.startsWith('#/components/')) {
- // Local reference within components
- const refKeys = refPath.replace('#/components/', '').split('/');
- let ref = components;
-
- for (const key of refKeys) {
- if (ref[key]) {
- ref = ref[key];
- } else {
- // Handle invalid references gracefully?
- return spec;
- }
- }
-
- return resolveRefs(ref, components, visitedItems);
- } else {
- // Handle external references (not implemented here)
- // You would need to fetch the external reference and resolve it.
- // Example: Fetch and resolve an external reference from a URL.
- }
- }
-
- // Recursively resolve references in nested objects
- for (const prop in spec) {
- spec[prop] = resolveRefs(spec[prop], components, visitedItems);
- }
-
- return spec;
-};
-
-const groupRequestsByTags = (requests) => {
- let _groups = {};
- let ungrouped = [];
- each(requests, (request) => {
- let tags = request.operationObject.tags || [];
- if (tags.length > 0) {
- let tag = tags[0]; // take first tag
- if (!_groups[tag]) {
- _groups[tag] = [];
- }
- _groups[tag].push(request);
- } else {
- ungrouped.push(request);
- }
- });
-
- let groups = Object.keys(_groups).map((groupName) => {
- return {
- name: groupName,
- requests: _groups[groupName]
- };
- });
-
- return [groups, ungrouped];
-};
-
-const getDefaultUrl = (serverObject) => {
- let url = serverObject.url;
- if (serverObject.variables) {
- each(serverObject.variables, (variable, variableName) => {
- let sub = variable.default || (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
- url = url.replace(`{${variableName}}`, sub);
- });
- }
- return url;
-};
-
-const getSecurity = (apiSpec) => {
- let defaultSchemes = apiSpec.security || [];
-
- let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
- if (Object.keys(securitySchemes) === 0) {
- return {
- supported: []
- };
- }
-
- return {
- supported: defaultSchemes.map((scheme) => {
- var schemeName = Object.keys(scheme)[0];
- return securitySchemes[schemeName];
- }),
- schemes: securitySchemes,
- getScheme: (schemeName) => {
- return securitySchemes[schemeName];
- }
- };
-};
-
-const parseOpenApiCollection = (data) => {
- const brunoCollection = {
- name: '',
- uid: uuid(),
- version: '1',
- items: [],
- environments: []
- };
-
- return new Promise((resolve, reject) => {
- try {
- const collectionData = resolveRefs(data);
- if (!collectionData) {
- reject(new BrunoError('Invalid OpenAPI collection. Failed to resolve refs.'));
- return;
- }
-
- // Currently parsing of openapi spec is "do your best", that is
- // allows "invalid" openapi spec
-
- // assumes v3 if not defined. v2 no supported yet
- if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
- reject(new BrunoError('Only OpenAPI v3 is supported currently.'));
- return;
- }
-
- // TODO what if info.title not defined?
- brunoCollection.name = collectionData.info.title;
- let servers = collectionData.servers || [];
- let baseUrl = servers[0] ? getDefaultUrl(servers[0]) : '';
- let securityConfig = getSecurity(collectionData);
-
- let allRequests = Object.entries(collectionData.paths)
- .map(([path, methods]) => {
- return Object.entries(methods)
- .filter(([method, op]) => {
- return ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
- method.toLowerCase()
- );
- })
- .map(([method, operationObject]) => {
- return {
- method: method,
- path: path,
- operationObject: operationObject,
- global: {
- server: baseUrl,
- security: securityConfig
- }
- };
- });
- })
- .reduce((acc, val) => acc.concat(val), []); // flatten
-
- let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
- let brunoFolders = groups.map((group) => {
- return {
- uid: uuid(),
- name: group.name,
- type: 'folder',
- items: group.requests.map(transformOpenapiRequestItem)
- };
- });
-
- let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
- let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
- brunoCollection.items = brunoCollectionItems;
- resolve(brunoCollection);
- } catch (err) {
- console.error(err);
- reject(new BrunoError('An error occurred while parsing the OpenAPI collection'));
- }
- });
-};
-
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
- .then(parseOpenApiCollection)
- .then(transformItemsInCollection)
- .then(hydrateSeqInCollection)
- .then(validateSchema)
+ .then((collection) => openApiToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
diff --git a/packages/bruno-app/src/utils/importers/postman-collection.js b/packages/bruno-app/src/utils/importers/postman-collection.js
index 902b1a2dc..b9cceed65 100644
--- a/packages/bruno-app/src/utils/importers/postman-collection.js
+++ b/packages/bruno-app/src/utils/importers/postman-collection.js
@@ -1,376 +1,25 @@
-import each from 'lodash/each';
-import get from 'lodash/get';
import fileDialog from 'file-dialog';
-import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
-import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
-import { postmanTranslation } from 'utils/importers/translators/postman_translation';
+import { safeParseJSON } from 'utils/common/index';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
- fileReader.onload = (e) => resolve(e.target.result);
+ fileReader.onload = (e) => resolve(safeParseJSON(e.target.result));
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
-const parseGraphQLRequest = (graphqlSource) => {
- try {
- let queryResultObject = {
- query: '',
- variables: ''
- };
-
- if (typeof graphqlSource === 'string') {
- graphqlSource = JSON.parse(text);
- }
-
- if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
- queryResultObject.variables = graphqlSource.variables;
- }
-
- if (graphqlSource.hasOwnProperty('query') && graphqlSource.query !== '') {
- queryResultObject.query = graphqlSource.query;
- }
-
- return queryResultObject;
- } catch (e) {
- return {
- query: '',
- variables: ''
- };
- }
-};
-
-const isItemAFolder = (item) => {
- return !item.request;
-};
-
-const convertV21Auth = (array) => {
- return array.reduce((accumulator, currentValue) => {
- accumulator[currentValue.key] = currentValue.value;
- return accumulator;
- }, {});
-};
-
-const translationLog = {};
-
-const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
- brunoParent.items = brunoParent.items || [];
- const folderMap = {};
-
- each(item, (i) => {
- if (isItemAFolder(i)) {
- const baseFolderName = i.name;
- let folderName = baseFolderName;
- let count = 1;
-
- while (folderMap[folderName]) {
- folderName = `${baseFolderName}_${count}`;
- count++;
- }
-
- const brunoFolderItem = {
- uid: uuid(),
- name: folderName,
- type: 'folder',
- items: []
- };
- brunoParent.items.push(brunoFolderItem);
- folderMap[folderName] = brunoFolderItem;
- if (i.item && i.item.length) {
- importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, options);
- }
- } else {
- if (i.request) {
- let url = '';
- if (typeof i.request.url === 'string') {
- url = i.request.url;
- } else {
- url = get(i, 'request.url.raw') || '';
- }
-
- const brunoRequestItem = {
- uid: uuid(),
- name: i.name,
- type: 'http-request',
- request: {
- url: url,
- method: i.request.method,
- auth: {
- mode: 'none',
- basic: null,
- bearer: null,
- awsv4: null
- },
- headers: [],
- params: [],
- body: {
- mode: 'none',
- json: null,
- text: null,
- xml: null,
- formUrlEncoded: [],
- multipartForm: []
- }
- }
- };
- /* struct of translation log
- {
- [collectionName]: {
- script: [index1, index2],
- test: [index1, index2]
- }
- }
- */
-
- // type could be script or test
- const pushTranslationLog = (type, index) => {
- if (!translationLog[i.name]) {
- translationLog[i.name] = {};
- }
- if (!translationLog[i.name][type]) {
- translationLog[i.name][type] = [];
- }
- translationLog[i.name][type].push(index + 1);
- };
- if (i.event) {
- i.event.forEach((event) => {
- if (event.listen === 'prerequest' && event.script && event.script.exec) {
- if (!brunoRequestItem.request.script) {
- brunoRequestItem.request.script = {};
- }
- if (Array.isArray(event.script.exec)) {
- brunoRequestItem.request.script.req = event.script.exec
- .map((line, index) =>
- options.enablePostmanTranslations.enabled
- ? postmanTranslation(line, () => pushTranslationLog('script', index))
- : `// ${line}`
- )
- .join('\n');
- } else {
- brunoRequestItem.request.script.req = options.enablePostmanTranslations.enabled
- ? postmanTranslation(event.script.exec[0], () => pushTranslationLog('script', 0))
- : `// ${event.script.exec[0]} `;
- }
- }
- if (event.listen === 'test' && event.script && event.script.exec) {
- if (!brunoRequestItem.request.tests) {
- brunoRequestItem.request.tests = {};
- }
- if (Array.isArray(event.script.exec)) {
- brunoRequestItem.request.tests = event.script.exec
- .map((line, index) =>
- options.enablePostmanTranslations.enabled
- ? postmanTranslation(line, () => pushTranslationLog('test', index))
- : `// ${line}`
- )
- .join('\n');
- } else {
- brunoRequestItem.request.tests = options.enablePostmanTranslations.enabled
- ? postmanTranslation(event.script.exec[0], () => pushTranslationLog('test', 0))
- : `// ${event.script.exec[0]} `;
- }
- }
- });
- }
-
- const bodyMode = get(i, 'request.body.mode');
- if (bodyMode) {
- if (bodyMode === 'formdata') {
- brunoRequestItem.request.body.mode = 'multipartForm';
- each(i.request.body.formdata, (param) => {
- brunoRequestItem.request.body.multipartForm.push({
- uid: uuid(),
- type: 'text',
- name: param.key,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
- }
-
- if (bodyMode === 'urlencoded') {
- brunoRequestItem.request.body.mode = 'formUrlEncoded';
- each(i.request.body.urlencoded, (param) => {
- brunoRequestItem.request.body.formUrlEncoded.push({
- uid: uuid(),
- name: param.key,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
- }
-
- if (bodyMode === 'raw') {
- let language = get(i, 'request.body.options.raw.language');
- if (!language) {
- language = searchLanguageByHeader(i.request.header);
- }
- if (language === 'json') {
- brunoRequestItem.request.body.mode = 'json';
- brunoRequestItem.request.body.json = i.request.body.raw;
- } else if (language === 'xml') {
- brunoRequestItem.request.body.mode = 'xml';
- brunoRequestItem.request.body.xml = i.request.body.raw;
- } else {
- brunoRequestItem.request.body.mode = 'text';
- brunoRequestItem.request.body.text = i.request.body.raw;
- }
- }
- }
-
- if (bodyMode === 'graphql') {
- brunoRequestItem.type = 'graphql-request';
- brunoRequestItem.request.body.mode = 'graphql';
- brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
- }
-
- each(i.request.header, (header) => {
- brunoRequestItem.request.headers.push({
- uid: uuid(),
- name: header.key,
- value: header.value,
- description: header.description,
- enabled: !header.disabled
- });
- });
-
- const auth = i.request.auth ?? parentAuth;
- if (auth?.[auth.type] && auth.type !== 'noauth') {
- let authValues = auth[auth.type];
- if (Array.isArray(authValues)) {
- authValues = convertV21Auth(authValues);
- }
- if (auth.type === 'basic') {
- brunoRequestItem.request.auth.mode = 'basic';
- brunoRequestItem.request.auth.basic = {
- username: authValues.username,
- password: authValues.password
- };
- } else if (auth.type === 'bearer') {
- brunoRequestItem.request.auth.mode = 'bearer';
- brunoRequestItem.request.auth.bearer = {
- token: authValues.token
- };
- } else if (auth.type === 'awsv4') {
- brunoRequestItem.request.auth.mode = 'awsv4';
- brunoRequestItem.request.auth.awsv4 = {
- accessKeyId: authValues.accessKey,
- secretAccessKey: authValues.secretKey,
- sessionToken: authValues.sessionToken,
- service: authValues.service,
- region: authValues.region,
- profileName: ''
- };
- }
- }
-
- each(get(i, 'request.url.query'), (param) => {
- brunoRequestItem.request.params.push({
- uid: uuid(),
- name: param.key,
- value: param.value,
- description: param.description,
- enabled: !param.disabled
- });
- });
-
- brunoParent.items.push(brunoRequestItem);
- }
- }
- });
-};
-
-const searchLanguageByHeader = (headers) => {
- let contentType;
- each(headers, (header) => {
- if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
- if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
- contentType = 'json';
- } else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
- contentType = 'xml';
- }
- return false;
- }
- });
- return contentType;
-};
-
-const importPostmanV2Collection = (collection, options) => {
- const brunoCollection = {
- name: collection.info.name,
- uid: uuid(),
- version: '1',
- items: [],
- environments: []
- };
-
- importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, options);
-
- return brunoCollection;
-};
-
-const parsePostmanCollection = (str, options) => {
+const postmanToBruno = (collection) => {
return new Promise((resolve, reject) => {
- try {
- let collection = JSON.parse(str);
- let schema = get(collection, 'info.schema');
-
- let v2Schemas = [
- 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
- 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
- ];
-
- if (v2Schemas.includes(schema)) {
- return resolve(importPostmanV2Collection(collection, options));
- }
-
- throw new BrunoError('Unknown postman schema');
- } catch (err) {
- console.log(err);
- if (err instanceof BrunoError) {
- return reject(err);
- }
-
- return reject(new BrunoError('Unable to parse the postman collection json file'));
- }
+ window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
+ .then(result => resolve(result))
+ .catch(err => {
+ console.error('Error converting Postman to Bruno via Electron:', err);
+ reject(new BrunoError('Conversion failed'));
+ });
});
};
-const logTranslationDetails = (translationLog) => {
- if (Object.keys(translationLog || {}).length > 0) {
- console.warn(
- `[Postman Translation Logs]
-Collections incomplete : ${Object.keys(translationLog || {}).length}` +
- `\nTotal lines incomplete : ${Object.values(translationLog || {}).reduce(
- (acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
- 0
- )}` +
- `\nSee details below :`,
- translationLog
- );
- }
-};
-
-const importCollection = (options) => {
- return new Promise((resolve, reject) => {
- fileDialog({ accept: 'application/json' })
- .then(readFile)
- .then((str) => parsePostmanCollection(str, options))
- .then(transformItemsInCollection)
- .then(hydrateSeqInCollection)
- .then(validateSchema)
- .then((collection) => resolve({ collection, translationLog }))
- .catch((err) => {
- console.log(err);
- reject(new BrunoError('Import collection failed'));
- })
- .then(() => logTranslationDetails(translationLog));
- });
-};
-
-export default importCollection;
+export { postmanToBruno, readFile };
diff --git a/packages/bruno-app/src/utils/importers/postman-environment.js b/packages/bruno-app/src/utils/importers/postman-environment.js
index 61c62311c..e4185deb5 100644
--- a/packages/bruno-app/src/utils/importers/postman-environment.js
+++ b/packages/bruno-app/src/utils/importers/postman-environment.js
@@ -1,66 +1,40 @@
-import each from 'lodash/each';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
+import { postmanToBrunoEnvironment } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
- fileReader.onload = (e) => resolve(e.target.result);
+ fileReader.onload = (e) => {
+ try {
+ let parsedPostmanEnvironment = JSON.parse(e.target.result);
+ resolve(parsedPostmanEnvironment);
+ } catch (err) {
+ console.error(err);
+ reject(new BrunoError('Unable to parse the postman environment json file'));
+ }
+ }
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
-const isSecret = (type) => {
- return type === 'secret';
-};
-
-const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
- brunoEnvironment.variables = brunoEnvironment.variables || [];
-
- each(values, (i) => {
- const brunoEnvironmentVariable = {
- name: i.key,
- value: i.value,
- enabled: i.enabled,
- secret: isSecret(i.type)
- };
-
- brunoEnvironment.variables.push(brunoEnvironmentVariable);
- });
-};
-
-const importPostmanEnvironment = (environment) => {
- const brunoEnvironment = {
- name: environment.name,
- variables: []
- };
-
- importPostmanEnvironmentVariables(brunoEnvironment, environment.values);
- return brunoEnvironment;
-};
-
-const parsePostmanEnvironment = (str) => {
- return new Promise((resolve, reject) => {
- try {
- let environment = JSON.parse(str);
- return resolve(importPostmanEnvironment(environment));
- } catch (err) {
- console.log(err);
- if (err instanceof BrunoError) {
- return reject(err);
- }
- return reject(new BrunoError('Unable to parse the postman environment json file'));
- }
- });
-};
-
const importEnvironment = () => {
return new Promise((resolve, reject) => {
- fileDialog({ accept: 'application/json' })
- .then(readFile)
- .then(parsePostmanEnvironment)
- .then((environment) => resolve(environment))
+ fileDialog({ multiple: true, accept: 'application/json' })
+ .then((files) => {
+ return Promise.all(
+ Object.values(files ?? {}).map((file) =>
+ readFile([file])
+ .then((environment) => postmanToBrunoEnvironment(environment))
+ .catch((err) => {
+ console.error(`Error processing file: ${file.name || 'undefined'}`, err);
+ throw err;
+ })
+ )
+ );
+ })
+ .then((environments) => resolve(environments))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import Environment failed'));
diff --git a/packages/bruno-app/src/utils/importers/translators/index.spec.js b/packages/bruno-app/src/utils/importers/translators/index.spec.js
deleted file mode 100644
index 6f9d2fb1a..000000000
--- a/packages/bruno-app/src/utils/importers/translators/index.spec.js
+++ /dev/null
@@ -1,139 +0,0 @@
-const { postmanTranslation } = require('./postman_translation'); // Adjust path as needed
-
-describe('postmanTranslation function', () => {
- test('should translate pm commands correctly', () => {
- const inputScript = `
- pm.environment.get('key');
- pm.environment.set('key', 'value');
- pm.variables.get('key');
- pm.variables.set('key', 'value');
- pm.collectionVariables.get('key');
- pm.collectionVariables.set('key', 'value');
- const data = pm.response.json();
- pm.expect(pm.environment.has('key')).to.be.true;
- `;
- const expectedOutput = `
- bru.getEnvVar('key');
- bru.setEnvVar('key', 'value');
- bru.getVar('key');
- bru.setVar('key', 'value');
- bru.getVar('key');
- bru.setVar('key', 'value');
- const data = res.getBody();
- expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
- `;
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
-
- test('should not translate non-pm commands', () => {
- const inputScript = `
- console.log('This script does not contain pm commands.');
- const data = pm.environment.get('key');
- pm.collectionVariables.set('key', data);
- `;
- const expectedOutput = `
- console.log('This script does not contain pm commands.');
- const data = bru.getEnvVar('key');
- bru.setVar('key', data);
- `;
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
-
- test('should comment non-translated pm commands', () => {
- const inputScript = "pm.test('random test', () => pm.variables.replaceIn('{{$guid}}'));";
- const expectedOutput = "// test('random test', () => pm.variables.replaceIn('{{$guid}}'));";
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
- test('should handle multiple pm commands on the same line', () => {
- const inputScript = "pm.environment.get('key'); pm.environment.set('key', 'value');";
- const expectedOutput = "bru.getEnvVar('key'); bru.setEnvVar('key', 'value');";
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
- test('should handle comments and other JavaScript code', () => {
- const inputScript = `
- // This is a comment
- const value = 'test';
- pm.environment.set('key', value);
- /*
- Multi-line comment
- */
- const result = pm.environment.get('key');
- console.log('Result:', result);
- `;
- const expectedOutput = `
- // This is a comment
- const value = 'test';
- bru.setEnvVar('key', value);
- /*
- Multi-line comment
- */
- const result = bru.getEnvVar('key');
- console.log('Result:', result);
- `;
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
-
- test('should handle nested commands and edge cases', () => {
- const inputScript = `
- const sampleObjects = [
- {
- key: pm.environment.get('key'),
- value: pm.variables.get('value')
- },
- {
- key: pm.collectionVariables.get('key'),
- value: pm.collectionVariables.get('value')
- }
- ];
- const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
- // this is a comment
- acc[key] = pm.collectionVariables.get(pm.environment.get(value));
- return acc; // Return the accumulator
- }, {});
- Object.values(dataTesting).forEach((data) => {
- pm.environment.set(data.key, pm.variables.get(data.value));
- });
- `;
- const expectedOutput = `
- const sampleObjects = [
- {
- key: bru.getEnvVar('key'),
- value: bru.getVar('value')
- },
- {
- key: bru.getVar('key'),
- value: bru.getVar('value')
- }
- ];
- const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
- // this is a comment
- acc[key] = bru.getVar(bru.getEnvVar(value));
- return acc; // Return the accumulator
- }, {});
- Object.values(dataTesting).forEach((data) => {
- bru.setEnvVar(data.key, bru.getVar(data.value));
- });
- `;
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
-
- test('should handle test commands', () => {
- const inputScript = `
- pm.test('Status code is 200', () => {
- pm.response.to.have.status(200);
- });
- pm.test('this test will fail', () => {
- return false
- });
- `;
- const expectedOutput = `
- test('Status code is 200', () => {
- expect(res.getStatus()).to.equal(200);
- });
- test('this test will fail', () => {
- return false
- });
- `;
- expect(postmanTranslation(inputScript)).toBe(expectedOutput);
- });
-});
diff --git a/packages/bruno-app/src/utils/importers/translators/postman_translation.js b/packages/bruno-app/src/utils/importers/translators/postman_translation.js
deleted file mode 100644
index 7844f91f8..000000000
--- a/packages/bruno-app/src/utils/importers/translators/postman_translation.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const replacements = {
- 'pm\\.environment\\.get\\(': 'bru.getEnvVar(',
- 'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
- 'pm\\.variables\\.get\\(': 'bru.getVar(',
- 'pm\\.variables\\.set\\(': 'bru.setVar(',
- 'pm\\.collectionVariables\\.get\\(': 'bru.getVar(',
- 'pm\\.collectionVariables\\.set\\(': 'bru.setVar(',
- 'pm\\.setNextRequest\\(': 'bru.setNextRequest(',
- 'pm\\.test\\(': 'test(',
- 'pm.response.to.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
- 'pm\\.response\\.to\\.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
- 'pm\\.response\\.json\\(': 'res.getBody(',
- 'pm\\.expect\\(': 'expect(',
- 'pm\\.environment\\.has\\(([^)]+)\\)': 'bru.getEnvVar($1) !== undefined && bru.getEnvVar($1) !== null',
- 'pm\\.response\\.code': 'res.getStatus()',
- 'pm\\.response\\.text\\(': 'res.getBody()?.toString('
-};
-
-const compiledReplacements = Object.entries(replacements).map(([pattern, replacement]) => ({
- regex: new RegExp(pattern, 'g'),
- replacement
-}));
-
-export const postmanTranslation = (script, logCallback) => {
- try {
- let modifiedScript = script;
- let modified = false;
- for (const { regex, replacement } of compiledReplacements) {
- if (regex.test(modifiedScript)) {
- modifiedScript = modifiedScript.replace(regex, replacement);
- modified = true;
- }
- }
- if (modifiedScript.includes('pm.')) {
- modifiedScript = modifiedScript.replace(/^(.*pm\..*)$/gm, '// $1');
- logCallback?.();
- }
- return modifiedScript;
- } catch (e) {
- return script;
- }
-};
diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js
index e76a7debd..529b38d8a 100644
--- a/packages/bruno-app/src/utils/network/index.js
+++ b/packages/bruno-app/src/utils/network/index.js
@@ -1,10 +1,14 @@
import { safeStringifyJSON } from 'utils/common';
-export const sendNetworkRequest = async (item, collection, environment, collectionVariables) => {
+export const sendNetworkRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
- sendHttpRequest(item, collection, environment, collectionVariables)
+ sendHttpRequest(item, collection, environment, runtimeVariables)
.then((response) => {
+ // if there is an error, we return the response object as is
+ if (response?.error) {
+ resolve(response)
+ }
resolve({
state: 'success',
data: response.data,
@@ -14,7 +18,8 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
size: response.size,
status: response.status,
statusText: response.statusText,
- duration: response.duration
+ duration: response.duration,
+ timeline: response.timeline
});
})
.catch((err) => reject(err));
@@ -22,31 +27,21 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
});
};
-const sendHttpRequest = async (item, collection, environment, collectionVariables) => {
+const sendHttpRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
- .invoke('send-http-request', item, collection, environment, collectionVariables)
+ .invoke('send-http-request', item, collection, environment, runtimeVariables)
.then(resolve)
.catch(reject);
});
};
-export const sendCollectionOauth2Request = async (collection, environment, collectionVariables) => {
+export const sendCollectionOauth2Request = async (collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
- ipcRenderer
- .invoke('send-collection-oauth2-request', collection, environment, collectionVariables)
- .then(resolve)
- .catch(reject);
- });
-};
-
-export const clearOauth2Cache = async (uid) => {
- return new Promise((resolve, reject) => {
- const { ipcRenderer } = window;
- ipcRenderer.invoke('clear-oauth2-cache', uid).then(resolve).catch(reject);
+ resolve({});
});
};
diff --git a/packages/bruno-app/src/utils/tabs/index.js b/packages/bruno-app/src/utils/tabs/index.js
index a6fa29dd7..b5cfe2b18 100644
--- a/packages/bruno-app/src/utils/tabs/index.js
+++ b/packages/bruno-app/src/utils/tabs/index.js
@@ -11,3 +11,10 @@ export const isItemAFolder = (item) => {
export const itemIsOpenedInTabs = (item, tabs) => {
return find(tabs, (t) => t.uid === item.uid);
};
+
+export const scrollToTheActiveTab = () => {
+ const activeTab = document.querySelector('.request-tab.active');
+ if (activeTab) {
+ activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js b/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js
new file mode 100644
index 000000000..adfb5dab9
--- /dev/null
+++ b/packages/bruno-app/src/utils/tests/collections/items-sequencing.spec.js
@@ -0,0 +1,126 @@
+import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
+
+describe('resetSequencesInFolder', () => {
+ it('should fix the sequences in the folder 1', () => {
+ const folder = {
+ items: [
+ { uid: '1', seq: 1 },
+ { uid: '2', seq: 3 },
+ { uid: '3', seq: 6 },
+ ],
+ };
+
+ const fixedFolder = resetSequencesInFolder(folder.items);
+ expect(fixedFolder).toEqual([
+ { uid: '1', seq: 1 },
+ { uid: '2', seq: 2 },
+ { uid: '3', seq: 3 },
+ ]);
+ });
+
+
+ it('should fix the sequences in the folder 2', () => {
+ const folder = {
+ items: [
+ { uid: '1', seq: 3 },
+ { uid: '2', seq: 1 },
+ { uid: '3', seq: 2 },
+ ],
+ };
+
+ const fixedFolder = resetSequencesInFolder(folder.items);
+ expect(fixedFolder).toEqual([
+ { uid: '2', seq: 1 },
+ { uid: '3', seq: 2 },
+ { uid: '1', seq: 3 },
+ ]);
+ });
+
+ it('should fix the sequences in the folder with missing sequences', () => {
+ const folder = {
+ items: [
+ { uid: '1', seq: 1 },
+ { uid: '2', type: 'folder' },
+ { uid: '3', type: 'folder' },
+ { uid: '4', seq: 7 },
+ ]
+ };
+
+ const fixedFolder = resetSequencesInFolder(folder.items);
+ expect(fixedFolder).toEqual([
+ { uid: '1', seq: 1 },
+ { uid: '2', seq: 2, type: 'folder' },
+ { uid: '3', seq: 3, type: 'folder' },
+ { uid: '4', seq: 4 },
+ ]);
+ });
+
+ it('should fix the sequences in the folder with same sequences', () => {
+ const folder = {
+ items: [
+ { uid: '1', seq: 2 },
+ { uid: '2', seq: 2 },
+ { uid: '3', seq: 3 },
+ { uid: '4', seq: 1 },
+ ],
+ };
+
+ const fixedFolder = resetSequencesInFolder(folder.items);
+ expect(fixedFolder).toEqual([
+ { uid: '4', seq: 1 },
+ { uid: '1', seq: 2 },
+ { uid: '2', seq: 3 },
+ { uid: '3', seq: 4 },
+ ]);
+ });
+});
+
+describe('isItemBetweenSequences', () => {
+ it('should return true if the item is between the sequences 1', () => {
+ const item = { uid: '1', seq: 2 };
+ const draggedSequence = 1;
+ const targetSequence = 5;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the item is between the sequences 2', () => {
+ const item = { uid: '1', seq: 2 };
+ const draggedSequence = 1;
+ const targetSequence = 5;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the item is between the sequences 3', () => {
+ const item = { uid: '1', seq: 4 };
+ const draggedSequence = 1;
+ const targetSequence = 5;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(true);
+ });
+
+ it('should return true if the item is between the sequences 4', () => {
+ const item = { uid: '1', seq: 1 };
+ const draggedSequence = 5;
+ const targetSequence = 1;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(true);
+ });
+
+ it('should return false if the item is between the sequences 1', () => {
+ const item = { uid: '1', seq: 1 };
+ const draggedSequence = 1;
+ const targetSequence = 5;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(false);
+ });
+
+ it('should return false if the item is between the sequences 2', () => {
+ const item = { uid: '1', seq: 5 };
+ const draggedSequence = 1;
+ const targetSequence = 5;
+ const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js
index 328b22cdc..3a82398a1 100644
--- a/packages/bruno-app/src/utils/url/index.js
+++ b/packages/bruno-app/src/utils/url/index.js
@@ -1,7 +1,9 @@
import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import each from 'lodash/each';
-import filter from 'lodash/filter';
+import find from 'lodash/find';
+
+import { interpolate } from '@usebruno/common';
const hasLength = (str) => {
if (!str || !str.length) {
@@ -14,16 +16,52 @@ const hasLength = (str) => {
};
export const parseQueryParams = (query) => {
- if (!query || !query.length) {
+ try {
+ if (!query || !query.length) {
+ return [];
+ }
+
+ return Array.from(new URLSearchParams(query.split('#')[0]).entries())
+ .map(([name, value]) => ({ name, value }));
+ } catch (error) {
+ console.error('Error parsing query params:', error);
+ return [];
+ }
+};
+
+export const parsePathParams = (url) => {
+ let uri = url.slice();
+
+ if (!uri || !uri.length) {
return [];
}
- let params = query.split('&').map((param) => {
- let [name, value = ''] = param.split('=');
- return { name, value };
- });
+ if (!uri.startsWith('http://') && !uri.startsWith('https://')) {
+ uri = `http://${uri}`;
+ }
- return filter(params, (p) => hasLength(p.name));
+ let paths;
+
+ try {
+ uri = new URL(uri);
+ paths = uri.pathname.split('/');
+ } catch (e) {
+ paths = uri.split('/');
+ }
+
+ paths = paths.reduce((acc, path) => {
+ if (path !== '' && path[0] === ':') {
+ let name = path.slice(1, path.length);
+ if (name) {
+ let isExist = find(acc, (path) => path.name === name);
+ if (!isExist) {
+ acc.push({ name: path.slice(1, path.length), value: '' });
+ }
+ }
+ }
+ return acc;
+ }, []);
+ return paths;
};
export const stringifyQueryParams = (params) => {
@@ -67,3 +105,53 @@ export const isValidUrl = (url) => {
return false;
}
};
+
+export const interpolateUrl = ({ url, globalEnvironmentVariables = {}, envVars, runtimeVariables, processEnvVars }) => {
+ if (!url || !url.length || typeof url !== 'string') {
+ return;
+ }
+
+ return interpolate(url, {
+ ...globalEnvironmentVariables,
+ ...envVars,
+ ...runtimeVariables,
+ process: {
+ env: {
+ ...processEnvVars
+ }
+ }
+ });
+};
+
+export const interpolateUrlPathParams = (url, params) => {
+ const getInterpolatedBasePath = (pathname, params) => {
+ return pathname
+ .split('/')
+ .map((segment) => {
+ if (segment.startsWith(':')) {
+ const pathParamName = segment.slice(1);
+ const pathParam = params.find((p) => p?.name === pathParamName && p?.type === 'path');
+ return pathParam ? pathParam.value : segment;
+ }
+ return segment;
+ })
+ .join('/');
+ };
+
+ let uri;
+
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = `http://${url}`;
+ }
+
+ try {
+ uri = new URL(url);
+ } catch (error) {
+ // if the URL is invalid, return the URL as is
+ return url;
+ }
+
+ const basePath = getInterpolatedBasePath(uri.pathname, params);
+
+ return `${uri.origin}${basePath}${uri?.search || ''}`;
+};
diff --git a/packages/bruno-app/src/utils/url/index.spec.js b/packages/bruno-app/src/utils/url/index.spec.js
index 02112cdf2..0645befee 100644
--- a/packages/bruno-app/src/utils/url/index.spec.js
+++ b/packages/bruno-app/src/utils/url/index.spec.js
@@ -1,4 +1,4 @@
-import { parseQueryParams, splitOnFirst } from './index';
+import { parseQueryParams, splitOnFirst, parsePathParams, interpolateUrl, interpolateUrlPathParams } from './index';
describe('Url Utils - parseQueryParams', () => {
it('should parse query - case 1', () => {
@@ -49,6 +49,68 @@ describe('Url Utils - parseQueryParams', () => {
{ name: 'b', value: '2' }
]);
});
+
+ it('should parse query with "=" character - case 9', () => {
+ const params = parseQueryParams('a=1&b={color=red,size=large}&c=3');
+ expect(params).toEqual([
+ { name: 'a', value: '1' },
+ { name: 'b', value: '{color=red,size=large}' },
+ { name: 'c', value: '3' }
+ ]);
+ });
+
+ it('should parse query with fragment - case 10', () => {
+ const params = parseQueryParams('a=1&b=2#I-AM-FRAGMENT');
+ expect(params).toEqual([
+ { name: 'a', value: '1' },
+ { name: 'b', value: '2' }
+ ]);
+ });
+});
+
+describe('Url Utils - parsePathParams', () => {
+ it('should parse path - case 1', () => {
+ const params = parsePathParams('www.example.com');
+ expect(params).toEqual([]);
+ });
+
+ it('should parse path - case 2', () => {
+ const params = parsePathParams('http://www.example.com');
+ expect(params).toEqual([]);
+ });
+
+ it('should parse path - case 3', () => {
+ const params = parsePathParams('https://www.example.com');
+ expect(params).toEqual([]);
+ });
+
+ it('should parse path - case 4', () => {
+ const params = parsePathParams('https://www.example.com/users/:id');
+ expect(params).toEqual([{ name: 'id', value: '' }]);
+ });
+
+ it('should parse path - case 5', () => {
+ const params = parsePathParams('https://www.example.com/users/:id/');
+ expect(params).toEqual([{ name: 'id', value: '' }]);
+ });
+
+ it('should parse path - case 6', () => {
+ const params = parsePathParams('https://www.example.com/users/:id/:');
+ expect(params).toEqual([{ name: 'id', value: '' }]);
+ });
+
+ it('should parse path - case 7', () => {
+ const params = parsePathParams('https://www.example.com/users/:id/posts/:id');
+ expect(params).toEqual([{ name: 'id', value: '' }]);
+ });
+
+ it('should parse path - case 8', () => {
+ const params = parsePathParams('https://www.example.com/users/:id/posts/:postId');
+ expect(params).toEqual([
+ { name: 'id', value: '' },
+ { name: 'postId', value: '' }
+ ]);
+ });
});
describe('Url Utils - splitOnFirst', () => {
@@ -77,3 +139,73 @@ describe('Url Utils - splitOnFirst', () => {
expect(params).toEqual(['a=1', 'b=2']);
});
});
+
+describe('Url Utils - interpolateUrl, interpolateUrlPathParams', () => {
+ it('should interpolate url correctly', () => {
+ const url = '{{host}}/api/:id/path?foo={{foo}}&bar={{bar}}&baz={{process.env.baz}}';
+ const expectedUrl = 'https://example.com/api/:id/path?foo=foo_value&bar=bar_value&baz=baz_value';
+
+ const envVars = { host: 'https://example.com', foo: 'foo_value' };
+ const runtimeVariables = { bar: 'bar_value' };
+ const processEnvVars = { baz: 'baz_value' };
+
+ const result = interpolateUrl({ url, envVars, runtimeVariables, processEnvVars });
+
+ expect(result).toEqual(expectedUrl);
+ });
+
+ it('should interpolate path params correctly', () => {
+ const url = 'https://example.com/api/:id/path';
+ const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
+ const expectedUrl = 'https://example.com/api/123/path';
+
+ const result = interpolateUrlPathParams(url, params);
+
+ expect(result).toEqual(expectedUrl);
+ });
+
+ it('should interpolate url and path params correctly', () => {
+ const url = '{{host}}/api/:id/path?foo={{foo}}&bar={{bar}}&baz={{process.env.baz}}';
+ const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
+ const expectedUrl = 'https://example.com/api/123/path?foo=foo_value&bar=bar_value&baz=baz_value';
+
+ const envVars = { host: 'https://example.com', foo: 'foo_value' };
+ const runtimeVariables = { bar: 'bar_value' };
+ const processEnvVars = { baz: 'baz_value' };
+
+ const intermediateResult = interpolateUrl({ url, envVars, runtimeVariables, processEnvVars });
+ const result = interpolateUrlPathParams(intermediateResult, params);
+
+ expect(result).toEqual(expectedUrl);
+ });
+
+ it('should handle empty params', () => {
+ const url = 'https://example.com/api';
+ const params = [];
+ const expectedUrl = 'https://example.com/api';
+
+ const result = interpolateUrlPathParams(url, params);
+
+ expect(result).toEqual(expectedUrl);
+ });
+
+ it('should handle invalid URL, case 1', () => {
+ const url = 'example.com/api/:id';
+ const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
+ const expectedUrl = 'http://example.com/api/123';
+
+ const result = interpolateUrlPathParams(url, params);
+
+ expect(result).toEqual(expectedUrl);
+ });
+
+ it('should handle invalid URL, case 2', () => {
+ const url = 'http://1.1.1.1:3000:id';
+ const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
+ const expectedUrl = 'http://1.1.1.1:3000:id';
+
+ const result = interpolateUrlPathParams(url, params);
+
+ expect(result).toEqual(expectedUrl);
+ });
+});
diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json
index c8e3b72ce..e1b74e191 100644
--- a/packages/bruno-cli/package.json
+++ b/packages/bruno-cli/package.json
@@ -13,8 +13,30 @@
"type": "git",
"url": "git+https://github.com/usebruno/bruno.git"
},
+ "keywords": [
+ "API",
+ "testing",
+ "automation",
+ "cli",
+ "command-line",
+ "bruno",
+ "HTTP requests",
+ "rest-api",
+ "api-client",
+ "api-automation",
+ "request-handling",
+ "mock-api",
+ "http-client",
+ "async",
+ "promise",
+ "javascript",
+ "nodejs",
+ "automation-tool",
+ "postman-alternative",
+ "api-scripting"
+ ],
"scripts": {
- "test": "jest"
+ "test": "node --experimental-vm-modules $(npx which jest)"
},
"files": [
"src",
@@ -24,12 +46,16 @@
"package.json"
],
"dependencies": {
- "@aws-sdk/credential-providers": "3.525.0",
+ "@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
+ "@usebruno/vm2": "^3.9.13",
+ "@usebruno/requests": "^0.1.0",
+ "@usebruno/converters": "^0.1.0",
"aws4-axios": "^3.3.0",
- "axios": "^1.5.1",
+ "axios": "^1.8.3",
+ "axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"decomment": "^0.9.5",
@@ -37,13 +63,12 @@
"fs-extra": "^10.1.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
- "inquirer": "^9.1.4",
- "json-bigint": "^1.0.0",
+ "iconv-lite": "^0.6.3",
+ "js-yaml": "^4.1.0",
"lodash": "^4.17.21",
- "mustache": "^4.2.0",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
- "vm2": "^3.9.13",
+ "tough-cookie": "^4.1.3",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}
diff --git a/packages/bruno-cli/readme.md b/packages/bruno-cli/readme.md
index db41a4d7c..c1d38f457 100644
--- a/packages/bruno-cli/readme.md
+++ b/packages/bruno-cli/readme.md
@@ -1,9 +1,11 @@
-# bruno-cli
+# Bruno CLI
With Bruno CLI, you can now run your API collections with ease using simple command line commands.
This makes it easier to test your APIs in different environments, automate your testing process, and integrate your API tests with your continuous integration and deployment workflows.
+For detailed documentation, visit [Bruno CLI Documentation](https://docs.usebruno.com/bru-cli/overview).
+
## Installation
To install the Bruno CLI, use the node package manager of your choice, such as NPM:
@@ -32,18 +34,108 @@ Or run all requests in a collection's subfolder:
bru run folder
```
-If you need to use an environment, you can specify it with the --env option:
+If you need to use an environment, you can specify it with the `--env` option:
```bash
bru run folder --env Local
```
-If you need to collect the results of your API tests, you can specify the --output option:
+If you need to collect the results of your API tests, you can specify the `--output` option:
```bash
bru run folder --output results.json
```
+If you need to run a set of requests that connect to peers with both publicly and privately signed certificates respectively, you can add private CA certificates via the `--cacert` option. By default, these certificates will be used in addition to the default truststore:
+
+```bash
+bru run folder --cacert myCustomCA.pem
+```
+
+If you need to limit the trusted CA to a specified set when validating the request peer, provide them via `--cacert` and in addition use `--ignore-truststore` to disable the default truststore:
+
+```bash
+bru run request.bru --cacert myCustomCA.pem --ignore-truststore
+```
+
+## Importing Collections
+
+You can import collections from other formats, such as OpenAPI, using the import command:
+
+```bash
+bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
+```
+
+You can also use the shorter form with aliases:
+
+```bash
+bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
+```
+
+This creates a Bruno collection directory that can be opened in Bruno.
+
+You can also import directly from a URL:
+
+```bash
+bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
+```
+
+You can also export the collection as a JSON file:
+
+```bash
+bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
+```
+
+Import Options:
+
+| Option | Details |
+| ------------------------- | -------------------------------------------------- |
+| --source, -s | Path to the source file or URL (required) |
+| --output, -o | Path to the output directory |
+| --output-file, -f | Path to the output JSON file |
+| --collection-name, -n | Name for the imported collection |
+| --insecure | Skip SSL certificate validation when fetching from URLs |
+
+## Command Line Options
+
+| Option | Details |
+| ---------------------------- | ----------------------------------------------------------------------------- |
+| -h, --help | Show help |
+| --version | Show version number |
+| -r | Indicates a recursive run (default: false) |
+| --cacert [string] | CA certificate to verify peer against |
+| --env [string] | Specify environment to run with |
+| --env-var [string] | Overwrite a single environment variable, multiple usages possible |
+| -o, --output [string] | Path to write file results to |
+| -f, --format [string] | Format of the file results; available formats are "json" (default) or "junit" |
+| --reporter-json [string] | Path to generate a JSON report |
+| --reporter-junit [string] | Path to generate a JUnit report |
+| --reporter-html [string] | Path to generate an HTML report |
+| --insecure | Allow insecure server connections |
+| --tests-only | Only run requests that have tests |
+| --bail | Stop execution after a failure of a request, test, or assertion |
+| --csv-file-path | CSV file to run the collection with |
+| --reporter--skip-all-headers | Skip all headers in the report |
+| --reporter-skip-headers | Skip specific headers in the report |
+| --client-cert-config | Client certificate configuration by passing a JSON file |
+| --delay [number] | Add delay to each request |
+
+## Scripting
+
+Bruno cli returns the following exit status codes:
+
+- `0` -- execution successful
+- `1` -- an assertion, test, or request in the executed collection failed
+- `2` -- the specified output directory does not exist
+- `3` -- the request chain seems to loop endlessly
+- `4` -- bru was called outside of a collection root directory
+- `5` -- the specified input file does not exist
+- `6` -- the specified environment does not exist
+- `7` -- the environment override was not a string or object
+- `8` -- an environment override is malformed
+- `9` -- an invalid output format was requested
+- `255` -- another error occurred
+
## Demo

@@ -57,6 +149,7 @@ Thank you for using Bruno CLI!
## Changelog
+
See [https://github.com/usebruno/bruno/releases](https://github.com/usebruno/bruno/releases)
## License
diff --git a/packages/bruno-cli/src/commands/import.js b/packages/bruno-cli/src/commands/import.js
new file mode 100644
index 000000000..dd12a8bc3
--- /dev/null
+++ b/packages/bruno-cli/src/commands/import.js
@@ -0,0 +1,230 @@
+const fs = require('fs');
+const path = require('path');
+const chalk = require('chalk');
+const jsyaml = require('js-yaml');
+const axios = require('axios');
+const { openApiToBruno } = require('@usebruno/converters');
+const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
+const { createCollectionFromBrunoObject } = require('../utils/collection');
+
+const command = 'import ';
+const desc = 'Import a collection from other formats';
+
+const builder = (yargs) => {
+ yargs
+ .positional('type', {
+ describe: 'Type of collection to import',
+ type: 'string',
+ choices: ['openapi']
+ })
+ .option('source', {
+ alias: 's',
+ describe: 'Path to the source file or URL',
+ type: 'string',
+ demandOption: true
+ })
+ .option('output', {
+ alias: 'o',
+ describe: 'Path to the output directory',
+ type: 'string',
+ conflicts: 'output-file'
+ })
+ .option('output-file', {
+ alias: 'f',
+ describe: 'Path to the output JSON file',
+ type: 'string',
+ conflicts: 'output'
+ })
+ .option('collection-name', {
+ alias: 'n',
+ describe: 'Name for the imported collection',
+ type: 'string'
+ })
+ .option('insecure', {
+ type: 'boolean',
+ describe: 'Skip SSL certificate verification when fetching from URLs',
+ default: false
+ })
+ .example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
+ .example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
+ .example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
+ .example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
+ .example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
+ .example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
+};
+
+const isUrl = (str) => {
+ try {
+ return Boolean(new URL(str));
+ } catch (error) {
+ return false;
+ }
+};
+
+const readOpenApiFile = async (source, options = {}) => {
+ try {
+ let content;
+
+ if (isUrl(source)) {
+ // Handle URL input
+ console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
+ try {
+ const axiosOptions = {
+ timeout: 30000, // 30 second timeout
+ maxContentLength: 10 * 1024 * 1024,
+ validateStatus: status => status >= 200 && status < 300
+ };
+
+ // Skip SSL certificate validation if insecure flag is set
+ if (options.insecure) {
+ console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
+ axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
+ }
+
+ const response = await axios.get(source, axiosOptions);
+ content = response.data;
+ } catch (error) {
+ if (error.code === 'ECONNABORTED') {
+ throw new Error('Request timed out. The server took too long to respond.');
+ } else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
+ error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
+ throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
+ } else if (error.response) {
+ throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
+ } else if (error.request) {
+ throw new Error(`No response received from server. Check the URL and your network connection.`);
+ } else {
+ throw new Error(`Error fetching URL: ${error.message}`);
+ }
+ }
+
+ // If response is already an object, return it directly
+ if (typeof content === 'object' && content !== null) {
+ return content;
+ }
+ } else {
+ // Handle file input
+ if (!await exists(source)) {
+ throw new Error(`File does not exist: ${source}`);
+ }
+ content = fs.readFileSync(source, 'utf8');
+ }
+
+ // If content is a string, try to parse as JSON or YAML
+ if (typeof content === 'string') {
+ try {
+ return JSON.parse(content);
+ } catch (jsonError) {
+ try {
+ return jsyaml.load(content);
+ } catch (yamlError) {
+ throw new Error('Failed to parse content as JSON or YAML');
+ }
+ }
+ }
+
+ return content;
+ } catch (error) {
+ // Let the specific error handling from above propagate
+ throw error;
+ }
+};
+
+const handler = async (argv) => {
+ try {
+ const { type, source, output, outputFile, collectionName, insecure } = argv;
+
+ if (!type || type !== 'openapi') {
+ console.error(chalk.red('Only OpenAPI import is supported currently'));
+ process.exit(1);
+ }
+
+ if (!source) {
+ console.error(chalk.red('Source file or URL is required'));
+ process.exit(1);
+ }
+
+ if (!output && !outputFile) {
+ console.error(chalk.red('Either --output or --output-file is required'));
+ process.exit(1);
+ }
+
+ console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
+
+ const openApiSpec = await readOpenApiFile(source, { insecure });
+
+ if (!openApiSpec) {
+ console.error(chalk.red('Failed to parse OpenAPI specification'));
+ process.exit(1);
+ }
+
+ console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
+
+ // Convert OpenAPI to Bruno format
+ let brunoCollection = openApiToBruno(openApiSpec);
+
+ // Override collection name if provided
+ if (collectionName) {
+ brunoCollection.name = collectionName;
+ }
+
+ if (outputFile) {
+ // Save as JSON file
+ const outputPath = path.resolve(outputFile);
+ fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
+ console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
+ } else if (output) {
+ const resolvedOutput = path.resolve(output);
+
+ // Check if output is an existing directory
+ const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
+
+ // Determine the final output directory
+ let outputDir;
+ if (isOutputDirectory) {
+ // If output is an existing directory, use collection name to create a subdirectory
+ const dirName = sanitizeName(brunoCollection.name);
+ outputDir = path.join(resolvedOutput, dirName);
+
+ // Check if this subfolder already exists
+ if (await exists(outputDir)) {
+ const dirContents = fs.readdirSync(outputDir);
+ if (dirContents.length > 0) {
+ console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
+ process.exit(1);
+ }
+ } else {
+ // Create the subfolder
+ fs.mkdirSync(outputDir, { recursive: true });
+ }
+ } else {
+ // If output doesn't exist or is not a directory, use it directly
+ outputDir = resolvedOutput;
+
+ // Check if parent directory exists
+ const parentDir = path.dirname(outputDir);
+ if (!await exists(parentDir)) {
+ console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
+ process.exit(1);
+ }
+
+ fs.mkdirSync(outputDir, { recursive: true });
+ }
+
+ await createCollectionFromBrunoObject(brunoCollection, outputDir);
+ console.log(chalk.green(`Bruno collection created at ${outputDir}`));
+ }
+ } catch (error) {
+ console.error(chalk.red(`Error: ${error.message}`));
+ process.exit(1);
+ }
+};
+
+module.exports = {
+ command,
+ desc,
+ builder,
+ handler,
+ isUrl,
+ readOpenApiFile
+};
\ No newline at end of file
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index 1d95afc75..5cf4f67f6 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -1,7 +1,8 @@
const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
-const { forOwn } = require('lodash');
+const { forOwn, cloneDeep } = require('lodash');
+const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
@@ -10,172 +11,95 @@ const makeHtmlOutput = require('../reporters/html');
const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
+const constants = require('../constants');
+const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
+const formatTestSummary = (label, maxLength, passed, failed, total, errorCount = 0, skippedCount = 0) => {
+ const parts = [
+ `${rpad(label, maxLength)} ${chalk.green(`${passed} passed`)}`
+ ];
+
+ if (failed > 0) parts.push(chalk.red(`${failed} failed`));
+ if (errorCount > 0) parts.push(chalk.red(`${errorCount} error`));
+ if (skippedCount > 0) parts.push(chalk.magenta(`${skippedCount} skipped`));
+
+ parts.push(`${total} total`);
+
+ return parts.join(', ');
+};
+
const printRunSummary = (results) => {
- let totalRequests = 0;
- let passedRequests = 0;
- let failedRequests = 0;
- let totalAssertions = 0;
- let passedAssertions = 0;
- let failedAssertions = 0;
- let totalTests = 0;
- let passedTests = 0;
- let failedTests = 0;
-
- for (const result of results) {
- totalRequests += 1;
- totalTests += result.testResults.length;
- totalAssertions += result.assertionResults.length;
- let anyFailed = false;
- let hasAnyTestsOrAssertions = false;
- for (const testResult of result.testResults) {
- hasAnyTestsOrAssertions = true;
- if (testResult.status === 'pass') {
- passedTests += 1;
- } else {
- anyFailed = true;
- failedTests += 1;
- }
- }
- for (const assertionResult of result.assertionResults) {
- hasAnyTestsOrAssertions = true;
- if (assertionResult.status === 'pass') {
- passedAssertions += 1;
- } else {
- anyFailed = true;
- failedAssertions += 1;
- }
- }
- if (!hasAnyTestsOrAssertions && result.error) {
- failedRequests += 1;
- } else {
- passedRequests += 1;
- }
- }
-
- const maxLength = 12;
-
- let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`;
- if (failedRequests > 0) {
- requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
- }
- requestSummary += `, ${totalRequests} total`;
-
- let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
- if (failedTests > 0) {
- assertSummary += `, ${chalk.red(`${failedTests} failed`)}`;
- }
- assertSummary += `, ${totalTests} total`;
-
- let testSummary = `${rpad('Assertions:', maxLength)} ${chalk.green(`${passedAssertions} passed`)}`;
- if (failedAssertions > 0) {
- testSummary += `, ${chalk.red(`${failedAssertions} failed`)}`;
- }
- testSummary += `, ${totalAssertions} total`;
-
- console.log('\n' + chalk.bold(requestSummary));
- console.log(chalk.bold(assertSummary));
- console.log(chalk.bold(testSummary));
-
- return {
+ const {
totalRequests,
passedRequests,
failedRequests,
+ skippedRequests,
+ errorRequests,
totalAssertions,
passedAssertions,
failedAssertions,
totalTests,
passedTests,
- failedTests
- };
-};
+ failedTests,
+ totalPreRequestTests,
+ passedPreRequestTests,
+ failedPreRequestTests,
+ totalPostResponseTests,
+ passedPostResponseTests,
+ failedPostResponseTests
+ } = getRunnerSummary(results);
-const getBruFilesRecursively = (dir, testsOnly) => {
- const environmentsPath = 'environments';
+ const maxLength = 12;
- const getFilesInOrder = (dir) => {
- let bruJsons = [];
+ const requestSummary = formatTestSummary('Requests:', maxLength, passedRequests, failedRequests, totalRequests, errorRequests, skippedRequests);
+ const testSummary = formatTestSummary('Tests:', maxLength, passedTests, failedTests, totalTests);
+ const assertSummary = formatTestSummary('Assertions:', maxLength, passedAssertions, failedAssertions, totalAssertions);
- const traverse = (currentPath) => {
- const filesInCurrentDir = fs.readdirSync(currentPath);
-
- if (currentPath.includes('node_modules')) {
- return;
- }
-
- for (const file of filesInCurrentDir) {
- const filePath = path.join(currentPath, file);
- const stats = fs.lstatSync(filePath);
-
- // todo: we might need a ignore config inside bruno.json
- if (
- stats.isDirectory() &&
- filePath !== environmentsPath &&
- !filePath.startsWith('.git') &&
- !filePath.startsWith('node_modules')
- ) {
- traverse(filePath);
- }
- }
-
- const currentDirBruJsons = [];
- for (const file of filesInCurrentDir) {
- if (['collection.bru', 'folder.bru'].includes(file)) {
- continue;
- }
- const filePath = path.join(currentPath, file);
- const stats = fs.lstatSync(filePath);
-
- if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
- const bruContent = fs.readFileSync(filePath, 'utf8');
- const bruJson = bruToJson(bruContent);
- const requestHasTests = bruJson.request?.tests;
- const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
-
- if (testsOnly) {
- if (requestHasTests || requestHasActiveAsserts) {
- currentDirBruJsons.push({
- bruFilepath: filePath,
- bruJson
- });
- }
- } else {
- currentDirBruJsons.push({
- bruFilepath: filePath,
- bruJson
- });
- }
- }
- }
-
- // order requests by sequence
- currentDirBruJsons.sort((a, b) => {
- const aSequence = a.bruJson.seq || 0;
- const bSequence = b.bruJson.seq || 0;
- return aSequence - bSequence;
- });
-
- bruJsons = bruJsons.concat(currentDirBruJsons);
- };
-
- traverse(dir);
- return bruJsons;
- };
-
- return getFilesInOrder(dir);
-};
-
-const getCollectionRoot = (dir) => {
- const collectionRootPath = path.join(dir, 'collection.bru');
- const exists = fs.existsSync(collectionRootPath);
- if (!exists) {
- return {};
+ let preRequestTestSummary = '';
+ if (totalPreRequestTests > 0) {
+ preRequestTestSummary = formatTestSummary('Pre-Request Tests:', maxLength, passedPreRequestTests, failedPreRequestTests, totalPreRequestTests);
}
- const content = fs.readFileSync(collectionRootPath, 'utf8');
- return collectionBruToJson(content);
+ let postResponseTestSummary = '';
+ if (totalPostResponseTests > 0) {
+ postResponseTestSummary = formatTestSummary('Post-Response Tests:', maxLength, passedPostResponseTests, failedPostResponseTests, totalPostResponseTests);
+ }
+
+ console.log('\n' + chalk.bold(requestSummary));
+ if (preRequestTestSummary) {
+ console.log(chalk.bold(preRequestTestSummary));
+ }
+ if (postResponseTestSummary) {
+ console.log(chalk.bold(postResponseTestSummary));
+ }
+ console.log(chalk.bold(testSummary));
+ console.log(chalk.bold(assertSummary));
+
+ return {
+ totalRequests,
+ passedRequests,
+ failedRequests,
+ skippedRequests,
+ errorRequests,
+ totalAssertions,
+ passedAssertions,
+ failedAssertions,
+ totalTests,
+ passedTests,
+ failedTests,
+ totalPreRequestTests,
+ passedPreRequestTests,
+ failedPreRequestTests,
+ totalPostResponseTests,
+ passedPostResponseTests,
+ failedPostResponseTests
+ }
+};
+
+const getJsSandboxRuntime = (sandbox) => {
+ return sandbox === 'safe' ? 'quickjs' : 'vm2';
};
const builder = async (yargs) => {
@@ -189,6 +113,17 @@ const builder = async (yargs) => {
type: 'string',
description: 'CA certificate to verify peer against'
})
+ .option('ignore-truststore', {
+ type: 'boolean',
+ default: false,
+ description:
+ 'The specified custom CA certificate (--cacert) will be used exclusively and the default truststore is ignored, if this option is specified. Evaluated in combination with "--cacert" only.'
+ })
+ .option('disable-cookies', {
+ type: 'boolean',
+ default: false,
+ description: 'Automatically save and sent cookies with requests'
+ })
.option('env', {
describe: 'Environment variables',
type: 'string'
@@ -197,6 +132,11 @@ const builder = async (yargs) => {
describe: 'Overwrite a single environment variable, multiple usages possible',
type: 'string'
})
+ .option('sandbox', {
+ describe: 'Javascript sandbox to use; available sandboxes are "developer" (default) or "safe"',
+ default: 'developer',
+ type: 'string'
+ })
.option('output', {
alias: 'o',
describe: 'Path to write file results to',
@@ -208,22 +148,62 @@ const builder = async (yargs) => {
default: 'json',
type: 'string'
})
+ .option('reporter-json', {
+ describe: 'Path to write json file results to',
+ type: 'string'
+ })
+ .option('reporter-junit', {
+ describe: 'Path to write junit file results to',
+ type: 'string'
+ })
+ .option('reporter-html', {
+ describe: 'Path to write html file results to',
+ type: 'string'
+ })
.option('insecure', {
type: 'boolean',
description: 'Allow insecure server connections'
})
.option('tests-only', {
type: 'boolean',
- description: 'Only run requests that have a test'
+ description: 'Only run requests that have a test or active assertion'
})
.option('bail', {
type: 'boolean',
description: 'Stop execution after a failure of a request, test, or assertion'
})
+ .option('reporter-skip-all-headers', {
+ type: 'boolean',
+ description: 'Omit headers from the reporter output',
+ default: false
+ })
+ .option('reporter-skip-headers', {
+ type: 'array',
+ description: 'Skip specific headers from the reporter output',
+ default: []
+ })
+ .option('client-cert-config', {
+ type: 'string',
+ description: 'Path to the Client certificate config file used for securing the connection in the request'
+ })
+ .option('--noproxy', {
+ type: 'boolean',
+ description: 'Disable all proxy settings (both collection-defined and system proxies)',
+ default: false
+ })
+ .option('delay', {
+ type:"number",
+ description: "Delay between each requests (in miliseconds)"
+ })
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
+ .example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
+ .example(
+ '$0 run --reporter-skip-headers "Authorization"',
+ 'Run all requests in a folder recursively with skipped headers from the reporter output'
+ )
.example(
'$0 run request.bru --env local --env-var secret=xxx',
'Run a request with the environment set to local and overwrite the variable secret with value xxx'
@@ -240,40 +220,100 @@ const builder = async (yargs) => {
'$0 run request.bru --output results.html --format html',
'Run a request and write the results to results.html in html format in the current directory'
)
- .example('$0 run request.bru --tests-only', 'Run all requests that have a test');
+ .example(
+ '$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
+ 'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
+ )
+ .example('$0 run request.bru --tests-only', 'Run all requests that have a test')
+ .example(
+ '$0 run request.bru --cacert myCustomCA.pem',
+ 'Use a custom CA certificate in combination with the default truststore when validating the peer of this request.'
+ )
+ .example(
+ '$0 run folder --cacert myCustomCA.pem --ignore-truststore',
+ 'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
+ )
+ .example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
+ .example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
+ .example('$0 run --noproxy', 'Run requests with system proxy disabled');
};
const handler = async function (argv) {
try {
- let { filename, cacert, env, envVar, insecure, r: recursive, output: outputPath, format, testsOnly, bail } = argv;
+ let {
+ filename,
+ cacert,
+ ignoreTruststore,
+ disableCookies,
+ env,
+ envVar,
+ insecure,
+ r: recursive,
+ output: outputPath,
+ format,
+ reporterJson,
+ reporterJunit,
+ reporterHtml,
+ sandbox,
+ testsOnly,
+ bail,
+ reporterSkipAllHeaders,
+ reporterSkipHeaders,
+ clientCertConfig,
+ noproxy,
+ delay
+ } = argv;
const collectionPath = process.cwd();
- // todo
- // right now, bru must be run from the root of the collection
- // will add support in the future to run it from anywhere inside the collection
- const brunoJsonPath = path.join(collectionPath, 'bruno.json');
- const brunoJsonExists = await exists(brunoJsonPath);
- if (!brunoJsonExists) {
- console.error(chalk.red(`You can run only at the root of a collection`));
- return;
- }
+ let collection = createCollectionJsonFromPathname(collectionPath);
+ const { root: collectionRoot, brunoConfig } = collection;
- const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
- const brunoConfig = JSON.parse(brunoConfigFile);
- const collectionRoot = getCollectionRoot(collectionPath);
+ if (clientCertConfig) {
+ try {
+ const clientCertConfigExists = await exists(clientCertConfig);
+ if (!clientCertConfigExists) {
+ console.error(chalk.red(`Client Certificate Config file "${clientCertConfig}" does not exist.`));
+ process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
+ }
+
+ const clientCertConfigFileContent = fs.readFileSync(clientCertConfig, 'utf8');
+ let clientCertConfigJson;
+
+ try {
+ clientCertConfigJson = JSON.parse(clientCertConfigFileContent);
+ } catch (err) {
+ console.error(chalk.red(`Failed to parse Client Certificate Config JSON: ${err.message}`));
+ process.exit(constants.EXIT_STATUS.ERROR_INVALID_JSON);
+ }
+
+ if (clientCertConfigJson?.enabled && Array.isArray(clientCertConfigJson?.certs)) {
+ if (brunoConfig.clientCertificates) {
+ brunoConfig.clientCertificates.certs.push(...clientCertConfigJson.certs);
+ } else {
+ brunoConfig.clientCertificates = { certs: clientCertConfigJson.certs };
+ }
+ console.log(chalk.green(`Client certificates has been added`));
+ } else {
+ console.warn(chalk.yellow(`Client certificate configuration is enabled, but it either contains no valid "certs" array or the added configuration has been set to false`));
+ }
+ } catch (err) {
+ console.error(chalk.red(`Unexpected error: ${err.message}`));
+ process.exit(constants.EXIT_STATUS.ERROR_UNKNOWN);
+ }
+ }
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
console.error(chalk.red(`File or directory ${filename} does not exist`));
- return;
+ process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
} else {
filename = './';
recursive = true;
}
- const collectionVariables = {};
+ const runtimeVariables = {};
let envVars = {};
if (env) {
@@ -282,7 +322,7 @@ const handler = async function (argv) {
if (!envPathExists) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}.bru`));
- return;
+ process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
const envBruContent = fs.readFileSync(envFile, 'utf8');
@@ -299,7 +339,7 @@ const handler = async function (argv) {
processVars = envVar;
} else {
console.error(chalk.red(`overridable environment variables not parsable: use name=value`));
- return;
+ process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE);
}
if (processVars && Array.isArray(processVars)) {
for (const value of processVars.values()) {
@@ -310,7 +350,7 @@ const handler = async function (argv) {
chalk.red(`Overridable environment variable not correct: use name=value - presented: `) +
chalk.dim(`${value}`)
);
- return;
+ process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE);
}
envVars[match[1]] = match[2];
}
@@ -324,6 +364,12 @@ const handler = async function (argv) {
if (insecure) {
options['insecure'] = true;
}
+ if (disableCookies) {
+ options['disableCookies'] = true;
+ }
+ if (noproxy) {
+ options['noproxy'] = true;
+ }
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@@ -336,10 +382,30 @@ const handler = async function (argv) {
}
}
}
+ options['ignoreTruststore'] = ignoreTruststore;
if (['json', 'junit', 'html'].indexOf(format) === -1) {
console.error(chalk.red(`Format must be one of "json", "junit or "html"`));
- return;
+ process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
+ }
+
+ let formats = {};
+
+ // Maintains back compat with --format and --output
+ if (outputPath && outputPath.length) {
+ formats[format] = outputPath;
+ }
+
+ if (reporterHtml && reporterHtml.length) {
+ formats['html'] = reporterHtml;
+ }
+
+ if (reporterJson && reporterJson.length) {
+ formats['json'] = reporterJson;
+ }
+
+ if (reporterJunit && reporterJunit.length) {
+ formats['junit'] = reporterJunit;
}
// load .env file at root of collection if it exists
@@ -360,103 +426,165 @@ const handler = async function (argv) {
const _isFile = isFile(filename);
let results = [];
- let bruJsons = [];
+ let requestItems = [];
if (_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
- const bruJson = bruToJson(bruContent);
- bruJsons.push({
- bruFilepath: filename,
- bruJson
- });
+ const requestItem = bruToJson(bruContent);
+ requestItem.pathname = path.resolve(collectionPath, filename);
+ requestItems.push(requestItem);
}
const _isDirectory = isDirectory(filename);
if (_isDirectory) {
if (!recursive) {
console.log(chalk.yellow('Running Folder \n'));
- const files = fs.readdirSync(filename);
- const bruFiles = files.filter((file) => file.endsWith('.bru'));
-
- for (const bruFile of bruFiles) {
- const bruFilepath = path.join(filename, bruFile);
- const bruContent = fs.readFileSync(bruFilepath, 'utf8');
- const bruJson = bruToJson(bruContent);
- const requestHasTests = bruJson.request?.tests;
- const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
- if (testsOnly) {
- if (requestHasTests || requestHasActiveAsserts) {
- bruJsons.push({
- bruFilepath,
- bruJson
- });
- }
- } else {
- bruJsons.push({
- bruFilepath,
- bruJson
- });
- }
- }
- bruJsons.sort((a, b) => {
- const aSequence = a.bruJson.seq || 0;
- const bSequence = b.bruJson.seq || 0;
- return aSequence - bSequence;
- });
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
-
- bruJsons = getBruFilesRecursively(filename, testsOnly);
}
+ const resolvedFilepath = path.resolve(filename);
+ if (resolvedFilepath === collectionPath) {
+ requestItems = getAllRequestsInFolder(collection?.items, recursive);
+ } else {
+ const folderItem = findItemInCollection(collection, resolvedFilepath);
+ if (folderItem) {
+ requestItems = getAllRequestsInFolder(folderItem.items, recursive);
+ }
+ }
+
+ if (testsOnly) {
+ requestItems = requestItems.filter((iter) => {
+ const requestHasTests = iter.request?.tests;
+ const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
+ return requestHasTests || requestHasActiveAsserts;
+ });
+ }
+ }
+
+ 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 requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
+ if (requestItem) {
+ const res = await runSingleRequest(
+ requestItem,
+ 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) {
- const iter = bruJsons[currentRequestIndex];
- const { bruFilepath, bruJson } = iter;
+ while (currentRequestIndex < requestItems.length) {
+ const requestItem = cloneDeep(requestItems[currentRequestIndex]);
+ const { pathname } = requestItem;
const start = process.hrtime();
const result = await runSingleRequest(
- bruFilepath,
- bruJson,
+ requestItem,
collectionPath,
- collectionVariables,
+ runtimeVariables,
envVars,
processEnvVars,
brunoConfig,
- collectionRoot
+ collectionRoot,
+ runtime,
+ collection,
+ runSingleRequestByPathname
);
+ const isLastRun = currentRequestIndex === requestItems.length - 1;
+ const isValidDelay = !Number.isNaN(delay) && delay > 0;
+ if(isValidDelay && !isLastRun){
+ console.log(chalk.yellow(`Waiting for ${delay}ms or ${(delay/1000).toFixed(3)}s before next request.`));
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+
+ if(Number.isNaN(delay) && !isLastRun){
+ console.log(chalk.red(`Ignoring delay because it's not a valid number.`));
+ }
+
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
- suitename: bruFilepath.replace('.bru', '')
+ suitename: pathname.replace('.bru', '')
});
+ if (reporterSkipAllHeaders) {
+ results.forEach((result) => {
+ result.request.headers = {};
+ result.response.headers = {};
+ });
+ }
+
+ const deleteHeaderIfExists = (headers, header) => {
+ if (headers && headers[header]) {
+ delete headers[header];
+ }
+ };
+
+ if (reporterSkipHeaders?.length) {
+ results.forEach((result) => {
+ if (result.request?.headers) {
+ reporterSkipHeaders.forEach((header) => {
+ deleteHeaderIfExists(result.request.headers, header);
+ });
+ }
+ if (result.response?.headers) {
+ reporterSkipHeaders.forEach((header) => {
+ deleteHeaderIfExists(result.response.headers, header);
+ });
+ }
+ });
+ }
+
+
// bail if option is set and there is a failure
if (bail) {
- const requestFailure = result?.error;
+ const requestFailure = result?.error && !result?.skipped;
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
- if (requestFailure || testFailure || assertionFailure) {
+ const preRequestTestFailure = result?.preRequestTestResults?.find((iter) => iter.status === 'fail');
+ const postResponseTestFailure = result?.postResponseTestResults?.find((iter) => iter.status === 'fail');
+ if (requestFailure || testFailure || assertionFailure || preRequestTestFailure || postResponseTestFailure) {
break;
}
}
// determine next request
const nextRequestName = result?.nextRequestName;
+
+ if (result?.shouldStopRunnerExecution) {
+ break;
+ }
+
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {
console.error(chalk.red(`Too many jumps, possible infinite loop`));
- process.exit(1);
+ process.exit(constants.EXIT_STATUS.ERROR_INFINITE_LOOP);
}
if (nextRequestName === null) {
break;
}
- const nextRequestIdx = bruJsons.findIndex((iter) => iter.bruJson.name === nextRequestName);
+ const nextRequestIdx = requestItems.findIndex((iter) => iter.name === nextRequestName);
if (nextRequestIdx >= 0) {
currentRequestIndex = nextRequestIdx;
} else {
@@ -472,37 +600,54 @@ const handler = async function (argv) {
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
- if (outputPath && outputPath.length) {
- const outputDir = path.dirname(outputPath);
- const outputDirExists = await exists(outputDir);
- if (!outputDirExists) {
- console.error(chalk.red(`Output directory ${outputDir} does not exist`));
- process.exit(1);
- }
-
+ const formatKeys = Object.keys(formats);
+ if (formatKeys && formatKeys.length > 0) {
const outputJson = {
summary,
results
};
- if (format === 'json') {
- fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2));
- } else if (format === 'junit') {
- makeJUnitOutput(results, outputPath);
- } else if (format === 'html') {
- makeHtmlOutput(outputJson, outputPath);
+ const reporters = {
+ 'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
+ 'junit': (path) => makeJUnitOutput(results, path),
+ 'html': (path) => makeHtmlOutput(outputJson, path),
}
- console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`)));
+ for (const formatter of Object.keys(formats))
+ {
+ const reportPath = formats[formatter];
+ const reporter = reporters[formatter];
+
+ // Skip formatters lacking an output path.
+ if (!reportPath || reportPath.length === 0) {
+ continue;
+ }
+
+ const outputDir = path.dirname(reportPath);
+ const outputDirExists = await exists(outputDir);
+ if (!outputDirExists) {
+ console.error(chalk.red(`Output directory ${outputDir} does not exist`));
+ process.exit(constants.EXIT_STATUS.ERROR_MISSING_OUTPUT_DIR);
+ }
+
+ if (!reporter) {
+ console.error(chalk.red(`Reporter ${formatter} does not exist`));
+ process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
+ }
+
+ reporter(reportPath);
+
+ console.log(chalk.dim(chalk.grey(`Wrote ${formatter} results to ${reportPath}`)));
+ }
}
- if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {
- process.exit(1);
+ if ((summary.failedAssertions + summary.failedTests + summary.failedPreRequestTests + summary.failedPostResponseTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
+ process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION);
}
} catch (err) {
console.log('Something went wrong');
console.error(chalk.red(err.message));
- process.exit(1);
+ process.exit(constants.EXIT_STATUS.ERROR_GENERIC);
}
};
diff --git a/packages/bruno-cli/src/constants.js b/packages/bruno-cli/src/constants.js
index a952f893b..cdc0d47aa 100644
--- a/packages/bruno-cli/src/constants.js
+++ b/packages/bruno-cli/src/constants.js
@@ -3,7 +3,32 @@ const { version } = require('../package.json');
const CLI_EPILOGUE = `Documentation: https://docs.usebruno.com (v${version})`;
const CLI_VERSION = version;
+// Exit codes
+const EXIT_STATUS = {
+ // One or more assertions, tests, or requests failed
+ ERROR_FAILED_COLLECTION: 1,
+ // The specified output dir does not exist
+ ERROR_MISSING_OUTPUT_DIR: 2,
+ // request chain caused an endless loop
+ ERROR_INFINITE_LOOP: 3,
+ // bru was called outside of a collection root
+ ERROR_NOT_IN_COLLECTION: 4,
+ // The specified file was not found
+ ERROR_FILE_NOT_FOUND: 5,
+ // The specified environment was not found
+ ERROR_ENV_NOT_FOUND: 6,
+ // Environment override not presented as string or object
+ ERROR_MALFORMED_ENV_OVERRIDE: 7,
+ // Environment overrides format incorrect
+ ERROR_INCORRECT_ENV_OVERRIDE: 8,
+ // Invalid output format requested
+ ERROR_INCORRECT_OUTPUT_FORMAT: 9,
+ // Everything else
+ ERROR_GENERIC: 255
+};
+
module.exports = {
CLI_EPILOGUE,
- CLI_VERSION
+ CLI_VERSION,
+ EXIT_STATUS
};
diff --git a/packages/bruno-cli/src/index.js b/packages/bruno-cli/src/index.js
index d9bc66550..038667072 100644
--- a/packages/bruno-cli/src/index.js
+++ b/packages/bruno-cli/src/index.js
@@ -20,7 +20,7 @@ const run = async () => {
.commandDir('commands')
.epilogue(CLI_EPILOGUE)
.usage('Usage: $0 [options]')
- .demandCommand(1, "Woof !! Let's play with some APIs !!")
+ .demandCommand(1, "Woof!! Let's play with some APIs!!")
.help('h')
.alias('h', 'help');
};
diff --git a/packages/bruno-cli/src/reporters/junit.js b/packages/bruno-cli/src/reporters/junit.js
index 30fb51939..e4a622722 100644
--- a/packages/bruno-cli/src/reporters/junit.js
+++ b/packages/bruno-cli/src/reporters/junit.js
@@ -62,7 +62,10 @@ const makeJUnitOutput = async (results, outputPath) => {
suite.testcase.push(testcase);
});
- if (result.error) {
+ if (result?.skipped) {
+ suite['@skipped'] = 1;
+ }
+ else if (result.error) {
suite['@errors'] = 1;
suite['@tests'] = 1;
suite.testcase = [
diff --git a/packages/bruno-cli/src/runner/awsv4auth-helper.js b/packages/bruno-cli/src/runner/awsv4auth-helper.js
index 4a2ff5aa2..8714ae39c 100644
--- a/packages/bruno-cli/src/runner/awsv4auth-helper.js
+++ b/packages/bruno-cli/src/runner/awsv4auth-helper.js
@@ -10,7 +10,8 @@ async function resolveAwsV4Credentials(request) {
if (isStrPresent(awsv4.profileName)) {
try {
credentialsProvider = fromIni({
- profile: awsv4.profileName
+ profile: awsv4.profileName,
+ ignoreCache: true
});
credentials = await credentialsProvider();
awsv4.accessKeyId = credentials.accessKeyId;
diff --git a/packages/bruno-cli/src/runner/interpolate-string.js b/packages/bruno-cli/src/runner/interpolate-string.js
index 052041670..e210be339 100644
--- a/packages/bruno-cli/src/runner/interpolate-string.js
+++ b/packages/bruno-cli/src/runner/interpolate-string.js
@@ -1,13 +1,13 @@
const { forOwn, cloneDeep } = require('lodash');
const { interpolate } = require('@usebruno/common');
-const interpolateString = (str, { envVars, collectionVariables, processEnvVars }) => {
+const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
processEnvVars = processEnvVars || {};
- collectionVariables = collectionVariables || {};
+ runtimeVariables = runtimeVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = envVars ? cloneDeep(envVars) : {};
@@ -24,10 +24,10 @@ const interpolateString = (str, { envVars, collectionVariables, processEnvVars }
});
});
- // collectionVariables take precedence over envVars
+ // runtimeVariables take precedence over envVars
const combinedVars = {
...envVars,
- ...collectionVariables,
+ ...runtimeVariables,
process: {
env: {
...processEnvVars
diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js
index 63ebdd4ca..7ec7041b5 100644
--- a/packages/bruno-cli/src/runner/interpolate-vars.js
+++ b/packages/bruno-cli/src/runner/interpolate-vars.js
@@ -1,5 +1,6 @@
const { interpolate } = require('@usebruno/common');
-const { each, forOwn, cloneDeep } = require('lodash');
+const { each, forOwn, cloneDeep, find } = require('lodash');
+const FormData = require('form-data');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -12,14 +13,17 @@ const getContentType = (headers = {}) => {
return contentType;
};
-const interpolateVars = (request, envVars = {}, collectionVariables = {}, processEnvVars = {}) => {
+const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
// we clone envVars because we don't want to modify the original object
- envVars = cloneDeep(envVars);
+ envVariables = cloneDeep(envVariables);
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
- forOwn(envVars, (value, key) => {
- envVars[key] = interpolate(value, {
+ forOwn(envVariables, (value, key) => {
+ envVariables[key] = interpolate(value, {
process: {
env: {
...processEnvVars
@@ -28,15 +32,18 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
});
});
- const _interpolate = (str) => {
+ const _interpolate = (str, { escapeJSONStrings } = {}) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
- // collectionVariables take precedence over envVars
+ // runtimeVariables take precedence over envVars
const combinedVars = {
- ...envVars,
...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...runtimeVariables,
process: {
env: {
...processEnvVars
@@ -44,7 +51,7 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
}
};
- return interpolate(str, combinedVars);
+ return interpolate(str, combinedVars, { escapeJSONStrings });
};
request.url = _interpolate(request.url);
@@ -60,32 +67,72 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
+ parsed = _interpolate(parsed, { escapeJSONStrings: true });
request.data = JSON.parse(parsed);
} catch (err) {}
}
if (typeof request.data === 'string') {
- if (request.data.length) {
- request.data = _interpolate(request.data);
+ if (request?.data?.length) {
+ request.data = _interpolate(request.data, { escapeJSONStrings: true });
}
}
} else if (contentType === 'application/x-www-form-urlencoded') {
if (typeof request.data === 'object') {
try {
- let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
- request.data = JSON.parse(parsed);
+ forOwn(request?.data, (value, key) => {
+ request.data[key] = _interpolate(value);
+ });
+ } catch (err) {}
+ }
+ } else if (contentType === 'multipart/form-data') {
+ if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
+ try {
+ request.data = request?.data?.map(d => ({
+ ...d,
+ value: _interpolate(d?.value)
+ }));
} catch (err) {}
}
} else {
request.data = _interpolate(request.data);
}
- each(request.params, (param) => {
+ each(request?.pathParams, (param) => {
param.value = _interpolate(param.value);
});
+ if (request?.pathParams?.length) {
+ let url = request.url;
+
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = `http://${url}`;
+ }
+
+ try {
+ url = new URL(url);
+ } catch (e) {
+ throw { message: 'Invalid URL format', originalError: e.message };
+ }
+
+ const interpolatedUrlPath = url.pathname
+ .split('/')
+ .filter((path) => path !== '')
+ .map((path) => {
+ if (path[0] !== ':') {
+ return '/' + path;
+ } else {
+ const name = path.slice(1);
+ const existingPathParam = request?.pathParams?.find((param) => param.type === 'path' && param.name === name);
+ return existingPathParam ? '/' + existingPathParam.value : '';
+ }
+ })
+ .join('');
+
+ const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
+ request.url = url.origin + interpolatedUrlPath + trailingSlash + url.search;
+ }
+
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);
@@ -100,13 +147,44 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
- if (request.auth) {
- const username = _interpolate(request.auth.username) || '';
- const password = _interpolate(request.auth.password) || '';
+ if (request.basicAuth) {
+ const username = _interpolate(request.basicAuth.username) || '';
+ const password = _interpolate(request.basicAuth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
- delete request.auth;
+ delete request.basicAuth;
+ }
+
+ if (request?.oauth2?.grantType) {
+ switch (request.oauth2.grantType) {
+ case 'password':
+ request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
+ request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
+ request.oauth2.username = _interpolate(request.oauth2.username) || '';
+ request.oauth2.password = _interpolate(request.oauth2.password) || '';
+ request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
+ request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
+ request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
+ request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
+ request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
+ request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
+ request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
+ break;
+ case 'client_credentials':
+ request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
+ request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
+ request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
+ request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
+ request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
+ request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
+ request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
+ request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
+ request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
+ break;
+ default:
+ break;
+ }
}
if (request.awsv4config) {
@@ -118,6 +196,15 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
request.awsv4config.profileName = _interpolate(request.awsv4config.profileName) || '';
}
+ // interpolate vars for ntlmConfig auth
+ if (request.ntlmConfig) {
+ request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
+ request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
+ request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
+ }
+
+ if(request?.auth) delete request.auth;
+
if (request) return request;
};
diff --git a/packages/bruno-cli/src/runner/oauth2.js b/packages/bruno-cli/src/runner/oauth2.js
new file mode 100644
index 000000000..f5335dc55
--- /dev/null
+++ b/packages/bruno-cli/src/runner/oauth2.js
@@ -0,0 +1,6 @@
+const { getOAuth2Token } = require('@usebruno/requests');
+const tokenStore = require('./tokenStore');
+
+module.exports = {
+ getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
+};
\ No newline at end of file
diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js
index 8b1b249db..5de2f3a42 100644
--- a/packages/bruno-cli/src/runner/prepare-request.js
+++ b/packages/bruno-cli/src/runner/prepare-request.js
@@ -1,23 +1,25 @@
const { get, each, filter } = require('lodash');
-const fs = require('fs');
-var JSONbig = require('json-bigint');
const decomment = require('decomment');
+const crypto = require('node:crypto');
+const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection');
+const { createFormData } = require('../utils/form-data');
-const prepareRequest = (request, collectionRoot) => {
+const prepareRequest = (item = {}, collection = {}) => {
+ const request = item?.request;
+ const brunoConfig = get(collection, 'brunoConfig', {});
const headers = {};
let contentTypeDefined = false;
- // collection headers
- each(get(collectionRoot, 'request.headers', []), (h) => {
- if (h.enabled) {
- headers[h.name] = h.value;
- if (h.name.toLowerCase() === 'content-type') {
- contentTypeDefined = true;
- }
- }
- });
+ const scriptFlow = brunoConfig?.scripts?.flow ?? 'sandwich';
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ if (requestTreePath && requestTreePath.length > 0) {
+ mergeHeaders(collection, request, requestTreePath);
+ mergeScripts(collection, request, requestTreePath, scriptFlow);
+ mergeVars(collection, request, requestTreePath);
+ mergeAuth(collection, request, requestTreePath);
+ }
- each(request.headers, (h) => {
+ each(get(request, 'headers', []), (h) => {
if (h.enabled) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
@@ -29,13 +31,16 @@ const prepareRequest = (request, collectionRoot) => {
let axiosRequest = {
method: request.method,
url: request.url,
- headers: headers
+ headers: headers,
+ name: item.name,
+ pathParams: request?.params?.filter((param) => param.type === 'path'),
+ responseType: 'arraybuffer'
};
- const collectionAuth = get(collectionRoot, 'request.auth');
- if (collectionAuth && request.auth.mode === 'inherit') {
+ const collectionAuth = get(collection, 'root.request.auth');
+ if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
- axiosRequest.auth = {
+ axiosRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
@@ -44,11 +49,106 @@ const prepareRequest = (request, collectionRoot) => {
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 (collectionAuth.mode === 'digest') {
+ axiosRequest.digestConfig = {
+ username: get(collectionAuth, 'digest.username'),
+ password: get(collectionAuth, 'digest.password')
+ };
+ }
+
+ if (collectionAuth.mode === 'oauth2') {
+ const grantType = get(collectionAuth, 'oauth2.grantType');
+
+ if (grantType === 'client_credentials') {
+ axiosRequest.oauth2 = {
+ grantType,
+ accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
+ clientId: get(collectionAuth, 'oauth2.clientId'),
+ clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
+ scope: get(collectionAuth, 'oauth2.scope'),
+ credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
+ tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
+ };
+ } else if (grantType === 'password') {
+ axiosRequest.oauth2 = {
+ grantType,
+ accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
+ username: get(collectionAuth, 'oauth2.username'),
+ password: get(collectionAuth, 'oauth2.password'),
+ clientId: get(collectionAuth, 'oauth2.clientId'),
+ clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
+ scope: get(collectionAuth, 'oauth2.scope'),
+ credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
+ tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
+ };
+ }
+ }
+ if (collectionAuth.mode === 'awsv4') {
+ axiosRequest.awsv4config = {
+ accessKeyId: get(collectionAuth, 'awsv4.accessKeyId'),
+ secretAccessKey: get(collectionAuth, 'awsv4.secretAccessKey'),
+ sessionToken: get(collectionAuth, 'awsv4.sessionToken'),
+ service: get(collectionAuth, 'awsv4.service'),
+ region: get(collectionAuth, 'awsv4.region'),
+ profileName: get(collectionAuth, 'awsv4.profileName')
+ };
+ }
+
+ if (collectionAuth.mode === 'ntlm') {
+ axiosRequest.ntlmConfig = {
+ username: get(collectionAuth, 'ntlm.username'),
+ password: get(collectionAuth, 'ntlm.password'),
+ domain: get(collectionAuth, 'ntlm.domain')
+ };
+ }
+
+ if (collectionAuth.mode === 'wsse') {
+ const username = get(collectionAuth, 'wsse.username', '');
+ const password = get(collectionAuth, 'wsse.password', '');
+
+ const ts = new Date().toISOString();
+ const nonce = crypto.randomBytes(16).toString('hex');
+
+ // Create the password digest using SHA-1 as required for WSSE
+ const hash = crypto.createHash('sha1');
+ hash.update(nonce + ts + password);
+ const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
+
+ // Construct the WSSE header
+ axiosRequest.headers[
+ 'X-WSSE'
+ ] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
+ }
+
+ console.log('axiosRequest', axiosRequest);
}
- if (request.auth) {
+ if (request.auth && request.auth.mode !== 'inherit') {
if (request.auth.mode === 'basic') {
- axiosRequest.auth = {
+ axiosRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
@@ -65,9 +165,92 @@ const prepareRequest = (request, collectionRoot) => {
};
}
+ if (request.auth.mode === 'ntlm') {
+ axiosRequest.ntlmConfig = {
+ username: get(request, 'auth.ntlm.username'),
+ password: get(request, 'auth.ntlm.password'),
+ domain: get(request, 'auth.ntlm.domain')
+ };
+ }
+
if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
}
+
+ if (request.auth.mode === 'wsse') {
+ const username = get(request, 'auth.wsse.username', '');
+ const password = get(request, 'auth.wsse.password', '');
+
+ const ts = new Date().toISOString();
+ const nonce = crypto.randomBytes(16).toString('hex');
+
+ // Create the password digest using SHA-1 as required for WSSE
+ const hash = crypto.createHash('sha1');
+ hash.update(nonce + ts + password);
+ const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
+
+ // Construct the WSSE header
+ axiosRequest.headers[
+ 'X-WSSE'
+ ] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
+ }
+
+ if (request.auth.mode === 'digest') {
+ axiosRequest.digestConfig = {
+ username: get(request, 'auth.digest.username'),
+ password: get(request, 'auth.digest.password')
+ };
+ }
+
+ if (request.auth.mode === 'oauth2') {
+ const grantType = get(request, 'auth.oauth2.grantType');
+
+ if (grantType === 'client_credentials') {
+ axiosRequest.oauth2 = {
+ grantType,
+ clientId: get(request, 'auth.oauth2.clientId'),
+ clientSecret: get(request, 'auth.oauth2.clientSecret'),
+ scope: get(request, 'auth.oauth2.scope'),
+ accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
+ tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
+ credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
+ tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
+ };
+ } else if (grantType === 'password') {
+ axiosRequest.oauth2 = {
+ grantType,
+ username: get(request, 'auth.oauth2.username'),
+ password: get(request, 'auth.oauth2.password'),
+ clientId: get(request, 'auth.oauth2.clientId'),
+ clientSecret: get(request, 'auth.oauth2.clientSecret'),
+ scope: get(request, 'auth.oauth2.scope'),
+ accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
+ tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
+ credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
+ tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
+ };
+ }
+ }
+
+ if (request.auth.mode === 'apikey') {
+ if (request.auth.apikey?.placement === 'header') {
+ axiosRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
+ }
+
+ if (request.auth.apikey?.placement === 'queryparams') {
+ if (axiosRequest.url && request.auth.apikey?.key) {
+ try {
+ const urlObj = new URL(request.url);
+ urlObj.searchParams.set(request.auth.apikey?.key, request.auth.apikey?.value);
+ axiosRequest.url = urlObj.toString();
+ } catch (error) {
+ console.error('Invalid URL:', request.url, error);
+ }
+ }
+ }
+ }
}
request.body = request.body || {};
@@ -77,9 +260,9 @@ const prepareRequest = (request, collectionRoot) => {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
- axiosRequest.data = JSONbig.parse(decomment(request.body.json));
- } catch (ex) {
- axiosRequest.data = request.body.json;
+ axiosRequest.data = decomment(request?.body?.json);
+ } catch (error) {
+ axiosRequest.data = request?.body?.json;
}
}
@@ -92,7 +275,7 @@ const prepareRequest = (request, collectionRoot) => {
if (request.body.mode === 'xml') {
if (!contentTypeDefined) {
- axiosRequest.headers['content-type'] = 'text/xml';
+ axiosRequest.headers['content-type'] = 'application/xml';
}
axiosRequest.data = request.body.xml;
}
@@ -111,19 +294,11 @@ const prepareRequest = (request, collectionRoot) => {
each(enabledParams, (p) => (params[p.name] = p.value));
axiosRequest.data = params;
}
-
+
if (request.body.mode === 'multipartForm') {
- const params = {};
- const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
- each(enabledParams, (p) => {
- if (p.type === 'file') {
- params[p.name] = p.value.map((path) => fs.createReadStream(path));
- } else {
- params[p.name] = p.value;
- }
- });
axiosRequest.headers['content-type'] = 'multipart/form-data';
- axiosRequest.data = params;
+ const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
+ axiosRequest.data = enabledParams;
}
if (request.body.mode === 'graphql') {
@@ -137,10 +312,19 @@ const prepareRequest = (request, collectionRoot) => {
axiosRequest.data = graphqlQuery;
}
- if (request.script && request.script.length) {
+ if (request.script) {
axiosRequest.script = request.script;
}
+ if (request.tests) {
+ axiosRequest.tests = request.tests;
+ }
+
+ axiosRequest.vars = request.vars;
+ axiosRequest.collectionVariables = request.collectionVariables;
+ axiosRequest.folderVariables = request.folderVariables;
+ axiosRequest.requestVariables = request.requestVariables;
+
return axiosRequest;
};
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index ec4767efb..6fd575f90 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -3,6 +3,7 @@ const qs = require('qs');
const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
+const tls = require('tls');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const FormData = require('form-data');
const prepareRequest = require('./prepare-request');
@@ -16,81 +17,126 @@ const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
-const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
-
+const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = require('../utils/proxy-util');
+const path = require('path');
+const { parseDataFromResponse } = require('../utils/common');
+const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
+const { createFormData } = require('../utils/form-data');
+const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
+const { NtlmClient } = require('axios-ntlm');
+const { addDigestInterceptor } = require('@usebruno/requests');
+
+const onConsoleLog = (type, args) => {
+ console[type](...args);
+};
const runSingleRequest = async function (
- filename,
- bruJson,
+ item,
collectionPath,
- collectionVariables,
+ runtimeVariables,
envVariables,
processEnvVars,
brunoConfig,
- collectionRoot
+ collectionRoot,
+ runtime,
+ collection,
+ runSingleRequestByPathname
) {
+ const { pathname: itemPathname } = item;
+ const relativeItemPathname = path.relative(collectionPath, itemPathname);
+
+ const logResults = (results, title) => {
+ if (results?.length) {
+ if (title) {
+ console.log(chalk.dim(title));
+ }
+ each(results, (r) => {
+ const message = r.description || `${r.lhsExpr}: ${r.rhsExpr}`;
+ if (r.status === 'pass') {
+ console.log(chalk.green(` ✓ `) + chalk.dim(message));
+ } else {
+ console.log(chalk.red(` ✕ `) + chalk.red(message));
+ if (r.error) {
+ console.log(chalk.red(` ${r.error}`));
+ }
+ }
+ });
+ }
+ };
+
try {
let request;
let nextRequestName;
+ let shouldStopRunnerExecution = false;
+ let preRequestTestResults = [];
+ let postResponseTestResults = [];
- request = prepareRequest(bruJson.request, collectionRoot);
+ request = prepareRequest(item, collection);
+
+ request.__bruno__executionMode = 'cli';
const scriptingConfig = get(brunoConfig, 'scripts', {});
-
- // make axios work in node using form data
- // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
- if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
- const form = new FormData();
- forOwn(request.data, (value, key) => {
- if (value instanceof Array) {
- each(value, (v) => form.append(key, v));
- } else {
- form.append(key, value);
- }
- });
- extend(request.headers, form.getHeaders());
- request.data = form;
- }
-
- // run pre-request vars
- const preRequestVars = get(bruJson, 'request.vars.req');
- if (preRequestVars?.length) {
- const varsRuntime = new VarsRuntime();
- varsRuntime.runPreRequestVars(
- preRequestVars,
- request,
- envVariables,
- collectionVariables,
- collectionPath,
- processEnvVars
- );
- }
+ scriptingConfig.runtime = runtime;
// run pre request script
- const requestScriptFile = compact([
- get(collectionRoot, 'request.script.req'),
- get(bruJson, 'request.script.req')
- ]).join(os.EOL);
+ const requestScriptFile = get(request, 'script.req');
+ const collectionName = collection?.brunoConfig?.name
if (requestScriptFile?.length) {
- const scriptRuntime = new ScriptRuntime();
+ const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
decomment(requestScriptFile),
request,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
- null,
+ onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runSingleRequestByPathname,
+ collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
+
+ if (result?.stopExecution) {
+ shouldStopRunnerExecution = true;
+ }
+
+ if (result?.skipRequest) {
+ return {
+ test: {
+ filename: relativeItemPathname
+ },
+ 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: null,
+ status: 'skipped',
+ skipped: true,
+ assertionResults: [],
+ testResults: [],
+ preRequestTestResults: result?.results || [],
+ postResponseTestResults: [],
+ shouldStopRunnerExecution
+ };
+ }
+
+ preRequestTestResults = result?.results || [];
}
// interpolate variables inside request
- interpolateVars(request, envVariables, collectionVariables, processEnvVars);
+ interpolateVars(request, envVariables, runtimeVariables, processEnvVars);
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
@@ -98,6 +144,7 @@ const runSingleRequest = async function (
const options = getOptions();
const insecure = get(options, 'insecure', false);
+ const noproxy = get(options, 'noproxy', false);
const httpsAgentRequestFields = {};
if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
@@ -106,7 +153,11 @@ const runSingleRequest = async function (
const caCert = caCertArray.find((el) => el);
if (caCert && caCert.length > 1) {
try {
- httpsAgentRequestFields['ca'] = fs.readFileSync(caCert);
+ let caCertBuffer = fs.readFileSync(caCert);
+ if (!options['ignoreTruststore']) {
+ caCertBuffer += '\n' + tls.rootCertificates.join('\n'); // Augment default truststore with custom CA certificates
+ }
+ httpsAgentRequestFields['ca'] = caCertBuffer;
} catch (err) {
console.log('Error reading CA cert file:' + caCert, err);
}
@@ -115,25 +166,37 @@ const runSingleRequest = async function (
const interpolationOptions = {
envVars: envVariables,
- collectionVariables,
+ runtimeVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
for (let clientCert of clientCertConfig) {
- const domain = interpolateString(clientCert.domain, interpolationOptions);
- const certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions);
- const keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions);
- if (domain && certFilePath && keyFilePath) {
+ const domain = interpolateString(clientCert?.domain, interpolationOptions);
+ const type = clientCert?.type || 'cert';
+ if (domain) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
-
if (request.url.match(hostRegex)) {
- try {
- httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
- httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
- } catch (err) {
- console.log('Error reading cert/key file', err);
+ if (type === 'cert') {
+ try {
+ let certFilePath = interpolateString(clientCert?.certFilePath, interpolationOptions);
+ certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
+ let keyFilePath = interpolateString(clientCert?.keyFilePath, interpolationOptions);
+ keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
+ httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
+ httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
+ } catch (err) {
+ console.log(chalk.red('Error reading cert/key file'), chalk.red(err?.message));
+ }
+ } else if (type === 'pfx') {
+ try {
+ let pfxFilePath = interpolateString(clientCert?.pfxFilePath, interpolationOptions);
+ pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath);
+ httpsAgentRequestFields['pfx'] = fs.readFileSync(pfxFilePath);
+ } catch (err) {
+ console.log(chalk.red('Error reading pfx file'), chalk.red(err?.message));
+ }
}
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
@@ -141,39 +204,92 @@ const runSingleRequest = async function (
}
}
- // set proxy if enabled
- const proxyEnabled = get(brunoConfig, 'proxy.enabled', false);
- const shouldProxy = shouldUseProxy(request.url, get(brunoConfig, 'proxy.bypassProxy', ''));
- if (proxyEnabled && shouldProxy) {
- const proxyProtocol = interpolateString(get(brunoConfig, 'proxy.protocol'), interpolationOptions);
- const proxyHostname = interpolateString(get(brunoConfig, 'proxy.hostname'), interpolationOptions);
- const proxyPort = interpolateString(get(brunoConfig, 'proxy.port'), interpolationOptions);
- const proxyAuthEnabled = get(brunoConfig, 'proxy.auth.enabled', false);
- const socksEnabled = proxyProtocol.includes('socks');
+ let proxyMode = 'off';
+ let proxyConfig = {};
- let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
- let proxyUri;
- if (proxyAuthEnabled) {
- const proxyAuthUsername = interpolateString(get(brunoConfig, 'proxy.auth.username'), interpolationOptions);
- const proxyAuthPassword = interpolateString(get(brunoConfig, 'proxy.auth.password'), interpolationOptions);
-
- proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
- } else {
- proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
+ const collectionProxyConfig = get(brunoConfig, 'proxy', {});
+ const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
+
+ if (noproxy) {
+ // If noproxy flag is set, don't use any proxy
+ proxyMode = 'off';
+ } else if (collectionProxyEnabled === true) {
+ // If collection proxy is enabled, use it
+ proxyConfig = collectionProxyConfig;
+ proxyMode = 'on';
+ } else if (collectionProxyEnabled === 'global') {
+ // If collection proxy is set to 'global', use system proxy
+ const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
+ if (http_proxy?.length || https_proxy?.length) {
+ proxyMode = 'system';
}
+ } else {
+ proxyMode = 'off';
+ }
- if (socksEnabled) {
- request.httpsAgent = new SocksProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new SocksProxyAgent(proxyUri);
+ if (proxyMode === 'on') {
+ const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
+ if (shouldProxy) {
+ const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
+ const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
+ const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
+ const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
+ const socksEnabled = proxyProtocol.includes('socks');
+ let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
+ let proxyUri;
+ if (proxyAuthEnabled) {
+ const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
+ const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
+
+ proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
+ } else {
+ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
+ }
+ if (socksEnabled) {
+ request.httpsAgent = new SocksProxyAgent(
+ proxyUri,
+ Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
+ );
+ request.httpAgent = new SocksProxyAgent(proxyUri);
+ } else {
+ request.httpsAgent = new PatchedHttpsProxyAgent(
+ proxyUri,
+ Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
+ );
+ request.httpAgent = new HttpProxyAgent(proxyUri);
+ }
} else {
- request.httpsAgent = new PatchedHttpsProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new HttpProxyAgent(proxyUri);
+ request.httpsAgent = new https.Agent({
+ ...httpsAgentRequestFields
+ });
+ }
+ } else if (proxyMode === 'system') {
+ const { http_proxy, https_proxy, no_proxy } = getSystemProxyEnvVariables();
+ const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
+ if (shouldUseSystemProxy) {
+ try {
+ if (http_proxy?.length) {
+ new URL(http_proxy);
+ request.httpAgent = new HttpProxyAgent(http_proxy);
+ }
+ } catch (error) {
+ throw new Error('Invalid system http_proxy');
+ }
+ try {
+ if (https_proxy?.length) {
+ new URL(https_proxy);
+ request.httpsAgent = new PatchedHttpsProxyAgent(
+ https_proxy,
+ Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
+ );
+ }
+ } catch (error) {
+ throw new Error('Invalid system https_proxy');
+ }
+ } else {
+ request.httpsAgent = new https.Agent({
+ ...httpsAgentRequestFields
+ });
}
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
request.httpsAgent = new https.Agent({
@@ -181,15 +297,94 @@ const runSingleRequest = async function (
});
}
+ //set cookies if enabled
+ if (!options.disableCookies) {
+ const cookieString = getCookieStringForUrl(request.url);
+ if (cookieString && typeof cookieString === 'string' && cookieString.length) {
+ const existingCookieHeaderName = Object.keys(request.headers).find(
+ name => name.toLowerCase() === 'cookie'
+ );
+ const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
+
+ // Helper function to parse cookies into an object
+ const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
+ const [name, ...rest] = cookie.split('=');
+ if (name && name.trim()) {
+ cookies[name.trim()] = rest.join('=').trim();
+ }
+ return cookies;
+ }, {});
+
+ const mergedCookies = {
+ ...parseCookies(existingCookieString),
+ ...parseCookies(cookieString),
+ };
+
+ const combinedCookieString = Object.entries(mergedCookies)
+ .map(([name, value]) => `${name}=${value}`)
+ .join('; ');
+
+ request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
+ }
+ }
+
// stringify the request url encoded params
if (request.headers['content-type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data);
}
+ if (request?.headers?.['content-type'] === 'multipart/form-data') {
+ if (!(request?.data instanceof FormData)) {
+ let form = createFormData(request.data, collectionPath);
+ request.data = form;
+ extend(request.headers, form.getHeaders());
+ }
+ }
+
+ let requestMaxRedirects = request.maxRedirects
+ request.maxRedirects = 0
+
+ // Set default value for requestMaxRedirects if not explicitly set
+ if (requestMaxRedirects === undefined) {
+ requestMaxRedirects = 5; // Default to 5 redirects
+ }
+
+ // Handle OAuth2 authentication
+ if (request.oauth2) {
+ try {
+ const token = await getOAuth2Token(request.oauth2);
+ if (token) {
+ const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2;
+
+ if (tokenPlacement === 'header') {
+ request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`;
+ } else if (tokenPlacement === 'url') {
+ try {
+ const url = new URL(request.url);
+ url.searchParams.set(tokenQueryKey, token);
+ request.url = url.toString();
+ } catch (error) {
+ console.error('Error applying OAuth2 token to URL:', error.message);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('OAuth2 token fetch error:', error.message);
+ }
+
+ // Remove oauth2 config from request to prevent it from being sent
+ delete request.oauth2;
+ }
+
let response, responseTime;
try {
- // run request
- const axiosInstance = makeAxiosInstance();
+
+ let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies });
+ if (request.ntlmConfig) {
+ axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
+ delete request.ntlmConfig;
+ }
+
if (request.awsv4config) {
// todo: make this happen in prepare-request.js
@@ -209,24 +404,39 @@ const runSingleRequest = async function (
delete request.awsv4config;
}
+ if (request.digestConfig) {
+ addDigestInterceptor(axiosInstance, request);
+ delete request.digestConfig;
+ }
+
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
+ const { data } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
+ response.data = data;
+
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
+
+ //save cookies if enabled
+ if (!options.disableCookies) {
+ saveCookies(request.url, response.headers);
+ }
} catch (err) {
if (err?.response) {
+ const { data } = parseDataFromResponse(err?.response);
+ err.response.data = data;
response = err.response;
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
- console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
+ console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
- filename: filename
+ filename: relativeItemPathname
},
request: {
method: request.method,
@@ -235,16 +445,20 @@ const runSingleRequest = async function (
data: request.data
},
response: {
- status: null,
+ status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
- error: err.message,
+ error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
+ status: 'error',
assertionResults: [],
testResults: [],
- nextRequestName: nextRequestName
+ preRequestTestResults,
+ postResponseTestResults,
+ nextRequestName: nextRequestName,
+ shouldStopRunnerExecution
};
}
}
@@ -252,104 +466,108 @@ const runSingleRequest = async function (
response.responseTime = responseTime;
console.log(
- chalk.green(stripExtension(filename)) +
- chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
+ chalk.green(stripExtension(relativeItemPathname)) +
+ chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
+ // Log pre-request test results
+ logResults(preRequestTestResults, 'Pre-Request Tests');
+
// run post-response vars
- const postResponseVars = get(bruJson, 'request.vars.res');
+ const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
- const varsRuntime = new VarsRuntime();
+ const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
postResponseVars,
request,
response,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
processEnvVars
);
}
// run post response script
- const responseScriptFile = compact([
- get(collectionRoot, 'request.script.res'),
- get(bruJson, 'request.script.res')
- ]).join(os.EOL);
+ const responseScriptFile = get(request, 'script.res');
if (responseScriptFile?.length) {
- const scriptRuntime = new ScriptRuntime();
+ const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runResponseScript(
decomment(responseScriptFile),
request,
response,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
null,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runSingleRequestByPathname,
+ collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
+
+ if (result?.stopExecution) {
+ shouldStopRunnerExecution = true;
+ }
+
+ postResponseTestResults = result?.results || [];
+ logResults(postResponseTestResults, 'Post-Response Tests');
}
- // run assertions
let assertionResults = [];
- const assertions = get(bruJson, 'request.assertions');
+ const assertions = get(item, 'request.assertions');
if (assertions) {
- const assertRuntime = new AssertRuntime();
+ const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
assertionResults = assertRuntime.runAssertions(
assertions,
request,
response,
envVariables,
- collectionVariables,
- collectionPath
+ runtimeVariables,
+ processEnvVars
);
-
- each(assertionResults, (r) => {
- if (r.status === 'pass') {
- console.log(chalk.green(` ✓ `) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
- } else {
- console.log(chalk.red(` ✕ `) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
- console.log(chalk.red(` ${r.error}`));
- }
- });
}
// run tests
let testResults = [];
- const testFile = compact([get(collectionRoot, 'request.tests'), get(bruJson, 'request.tests')]).join(os.EOL);
+ const testFile = get(request, 'tests');
if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
+ const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const result = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
null,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runSingleRequestByPathname,
+ collectionName
);
testResults = get(result, 'results', []);
+
+ if (result?.nextRequestName !== undefined) {
+ nextRequestName = result.nextRequestName;
+ }
+
+ if (result?.stopExecution) {
+ shouldStopRunnerExecution = true;
+ }
+
+ logResults(testResults, 'Tests');
}
- if (testResults?.length) {
- each(testResults, (testResult) => {
- if (testResult.status === 'pass') {
- console.log(chalk.green(` ✓ `) + chalk.dim(testResult.description));
- } else {
- console.log(chalk.red(` ✕ `) + chalk.red(testResult.description));
- }
- });
- }
+
+ logResults(assertionResults, 'Assertions');
return {
test: {
- filename: filename
+ filename: relativeItemPathname
},
request: {
method: request.method,
@@ -365,15 +583,19 @@ const runSingleRequest = async function (
responseTime
},
error: null,
+ status: 'pass',
assertionResults,
testResults,
- nextRequestName: nextRequestName
+ preRequestTestResults,
+ postResponseTestResults,
+ nextRequestName: nextRequestName,
+ shouldStopRunnerExecution
};
} catch (err) {
- console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
+ console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
- filename: filename
+ filename: relativeItemPathname
},
request: {
method: null,
@@ -382,15 +604,18 @@ const runSingleRequest = async function (
data: null
},
response: {
- status: null,
+ status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
+ status: 'error',
error: err.message,
assertionResults: [],
- testResults: []
+ testResults: [],
+ preRequestTestResults: [],
+ postResponseTestResults: []
};
}
};
diff --git a/packages/bruno-cli/src/runner/tokenStore.js b/packages/bruno-cli/src/runner/tokenStore.js
new file mode 100644
index 000000000..1bc5c3273
--- /dev/null
+++ b/packages/bruno-cli/src/runner/tokenStore.js
@@ -0,0 +1,22 @@
+// In-memory token store implementation for OAuth2 tokens
+const tokenStore = {
+ tokens: new Map(),
+
+ // Save a token with optional expiry information
+ async saveToken(serviceId, account, token) {
+ this.tokens.set(`${serviceId}:${account}`, token);
+ return true;
+ },
+
+ // Get a token
+ async getToken(serviceId, account) {
+ return this.tokens.get(`${serviceId}:${account}`);
+ },
+
+ // Delete a token
+ async deleteToken(serviceId, account) {
+ return this.tokens.delete(`${serviceId}:${account}`);
+ }
+};
+
+module.exports = tokenStore;
\ No newline at end of file
diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js
index 225156484..e919412e7 100644
--- a/packages/bruno-cli/src/utils/axios-instance.js
+++ b/packages/bruno-cli/src/utils/axios-instance.js
@@ -1,4 +1,47 @@
const axios = require('axios');
+const { CLI_VERSION } = require('../constants');
+const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
+
+const redirectResponseCodes = [301, 302, 303, 307, 308];
+const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
+
+const saveCookies = (url, headers) => {
+ if (headers['set-cookie']) {
+ let setCookieHeaders = Array.isArray(headers['set-cookie'])
+ ? headers['set-cookie']
+ : [headers['set-cookie']];
+ for (let setCookieHeader of setCookieHeaders) {
+ if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
+ addCookieToJar(setCookieHeader, url);
+ }
+ }
+ }
+};
+
+const createRedirectConfig = (error, redirectUrl) => {
+ const requestConfig = {
+ ...error.config,
+ url: redirectUrl,
+ headers: { ...error.config.headers }
+ };
+
+ const statusCode = error.response.status;
+ const originalMethod = (error.config.method || 'get').toLowerCase();
+
+ // For 301, 302, 303: change method to GET unless it was HEAD
+ if (METHOD_CHANGING_REDIRECTS.includes(statusCode) && originalMethod !== 'head') {
+ requestConfig.method = 'get';
+ requestConfig.data = undefined;
+
+ // Clean up headers that are no longer relevant
+ delete requestConfig.headers['content-length'];
+ delete requestConfig.headers['Content-Length'];
+ delete requestConfig.headers['content-type'];
+ delete requestConfig.headers['Content-Type'];
+ }
+
+ return requestConfig;
+};
/**
* Function that configures axios with timing interceptors
@@ -6,12 +49,29 @@ const axios = require('axios');
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
-function makeAxiosInstance() {
+function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
+ let redirectCount = 0;
+
/** @type {axios.AxiosInstance} */
- const instance = axios.create();
+ const instance = axios.create({
+ proxy: false,
+ maxRedirects: 0,
+ headers: {
+ "User-Agent": `bruno-runtime/${CLI_VERSION}`
+ }
+ });
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
+
+ // Add cookies to request if available and not disabled
+ if (!disableCookies) {
+ const cookieString = getCookieStringForUrl(config.url);
+ if (cookieString && typeof cookieString === 'string' && cookieString.length) {
+ config.headers['cookie'] = cookieString;
+ }
+ }
+
return config;
});
@@ -20,6 +80,8 @@ function makeAxiosInstance() {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
+ redirectCount = 0;
+
return response;
},
(error) => {
@@ -27,6 +89,42 @@ function makeAxiosInstance() {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
+
+ if (redirectResponseCodes.includes(error.response.status)) {
+ if (redirectCount >= requestMaxRedirects) {
+ // todo: needs to be discussed whether the original error response message should be modified or not
+ return Promise.reject(error);
+ }
+
+ const locationHeader = error.response.headers.location;
+ if (!locationHeader) {
+ // todo: needs to be discussed whether the original error response message should be modified or not
+ return Promise.reject(error);
+ }
+
+ redirectCount++;
+ let redirectUrl = locationHeader;
+
+ if (!locationHeader.match(/^https?:\/\//i)) {
+ const URL = require('url');
+ redirectUrl = URL.resolve(error.config.url, locationHeader);
+ }
+
+ if (!disableCookies){
+ saveCookies(redirectUrl, error.response.headers);
+ }
+
+ const requestConfig = createRedirectConfig(error, redirectUrl);
+
+ if (!disableCookies) {
+ const cookieString = getCookieStringForUrl(redirectUrl);
+ if (cookieString && typeof cookieString === 'string' && cookieString.length) {
+ requestConfig.headers['cookie'] = cookieString;
+ }
+ }
+
+ return instance(requestConfig);
+ }
}
return Promise.reject(error);
}
diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js
index 34fb09c6b..07844a455 100644
--- a/packages/bruno-cli/src/utils/bru.js
+++ b/packages/bruno-cli/src/utils/bru.js
@@ -1,19 +1,12 @@
const _ = require('lodash');
-const Mustache = require('mustache');
const { bruToEnvJsonV2, bruToJsonV2, collectionBruToJson: _collectionBruToJson } = require('@usebruno/lang');
-// override the default escape function to prevent escaping
-Mustache.escape = function (value) {
- return value;
-};
-
const collectionBruToJson = (bru) => {
try {
const json = _collectionBruToJson(bru);
const transformedJson = {
request: {
- params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@@ -22,6 +15,17 @@ const collectionBruToJson = (bru) => {
}
};
+ // add meta if it exists
+ // this is only for folder bru file
+ // in the future, all of this will be replaced by standard bru lang
+ const sequence = _.get(json, 'meta.seq');
+ if (json?.meta) {
+ transformedJson.meta = {
+ name: json.meta.name,
+ seq: !isNaN(sequence) ? Number(sequence) : 1
+ };
+ }
+
return transformedJson;
} catch (error) {
return Promise.reject(error);
@@ -60,12 +64,12 @@ const bruToJson = (bru) => {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
auth: _.get(json, 'auth', {}),
- params: _.get(json, 'query', []),
+ params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
body: _.get(json, 'body', {}),
vars: _.get(json, 'vars', []),
assertions: _.get(json, 'assertions', []),
- script: _.get(json, 'script', ''),
+ script: _.get(json, 'script', {}),
tests: _.get(json, 'tests', '')
}
};
@@ -96,7 +100,7 @@ const getEnvVars = (environment = {}) => {
const envVars = {};
_.each(variables, (variable) => {
if (variable.enabled) {
- envVars[variable.name] = Mustache.escape(variable.value);
+ envVars[variable.name] = variable.value;
}
});
diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js
new file mode 100644
index 000000000..649fb2a33
--- /dev/null
+++ b/packages/bruno-cli/src/utils/collection.js
@@ -0,0 +1,493 @@
+const { get, each, find, compact } = require('lodash');
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
+const { sanitizeName } = require('./filesystem');
+const { bruToJson, collectionBruToJson } = require('./bru');
+const constants = require('../constants');
+const chalk = require('chalk');
+
+const createCollectionJsonFromPathname = (collectionPath) => {
+ const environmentsPath = path.join(collectionPath, `environments`);
+
+ // get the collection bruno json config [/bruno.json]
+ const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
+
+ // get the collection root [/collection.bru]
+ const collectionRoot = getCollectionRoot(collectionPath);
+
+ // get the collection items recursively
+ const traverse = (currentPath) => {
+ const filesInCurrentDir = fs.readdirSync(currentPath);
+ if (currentPath.includes('node_modules')) {
+ return;
+ }
+ const currentDirItems = [];
+ for (const file of filesInCurrentDir) {
+ const filePath = path.join(currentPath, file);
+ const stats = fs.lstatSync(filePath);
+ if (stats.isDirectory()) {
+ if (filePath === environmentsPath) continue;
+ if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
+
+ // get the folder root
+ let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
+ const folderBruJson = getFolderRoot(filePath);
+ if (folderBruJson) {
+ folderItem.root = folderBruJson;
+ folderItem.seq = folderBruJson.meta.seq;
+ }
+ currentDirItems.push(folderItem);
+ }
+ else {
+ if (['collection.bru', 'folder.bru'].includes(file)) continue;
+ if (path.extname(filePath) !== '.bru') continue;
+
+ // get the request item
+ const bruContent = fs.readFileSync(filePath, 'utf8');
+ const requestItem = bruToJson(bruContent);
+ currentDirItems.push({
+ name: file,
+ pathname: filePath,
+ ...requestItem
+ });
+ }
+ }
+ let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
+ let sortedFolderItems = currentDirFolderItems?.sort((a, b) => a.seq - b.seq);
+
+ let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
+ let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
+
+ return sortedFolderItems?.concat(sortedRequestItems);
+ };
+ let collectionItems = traverse(collectionPath);
+
+ let collection = {
+ brunoConfig,
+ root: collectionRoot,
+ pathname: collectionPath,
+ items: collectionItems
+ }
+
+ return collection;
+};
+
+const getCollectionBrunoJsonConfig = (dir) => {
+ // right now, bru must be run from the root of the collection
+ // will add support in the future to run it from anywhere inside the collection
+ const brunoJsonPath = path.join(dir, 'bruno.json');
+ const brunoJsonExists = fs.existsSync(brunoJsonPath);
+ if (!brunoJsonExists) {
+ console.error(chalk.red(`You can run only at the root of a collection`));
+ process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
+ }
+
+ const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
+ const brunoConfig = JSON.parse(brunoConfigFile);
+ return brunoConfig;
+}
+
+const getCollectionRoot = (dir) => {
+ const collectionRootPath = path.join(dir, 'collection.bru');
+ const exists = fs.existsSync(collectionRootPath);
+ if (!exists) {
+ return {};
+ }
+
+ const content = fs.readFileSync(collectionRootPath, 'utf8');
+ return collectionBruToJson(content);
+};
+
+const getFolderRoot = (dir) => {
+ const folderRootPath = path.join(dir, 'folder.bru');
+ const exists = fs.existsSync(folderRootPath);
+ if (!exists) {
+ return null;
+ }
+
+ const content = fs.readFileSync(folderRootPath, 'utf8');
+ return collectionBruToJson(content);
+};
+
+const mergeHeaders = (collection, request, requestTreePath) => {
+ let headers = new Map();
+
+ let collectionHeaders = get(collection, 'root.request.headers', []);
+ collectionHeaders.forEach((header) => {
+ if (header.enabled) {
+ headers.set(header.name, header.value);
+ }
+ });
+
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let _headers = get(i, 'root.request.headers', []);
+ _headers.forEach((header) => {
+ if (header.enabled) {
+ headers.set(header.name, 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);
+ }
+ });
+ }
+ }
+
+ request.headers = Array.from(headers, ([name, value]) => ({ name, value, enabled: true }));
+};
+
+const mergeVars = (collection, request, requestTreePath) => {
+ let reqVars = new Map();
+ let collectionRequestVars = get(collection, 'root.request.vars.req', []);
+ let collectionVariables = {};
+ collectionRequestVars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ collectionVariables[_var.name] = _var.value;
+ }
+ });
+ let folderVariables = {};
+ let requestVariables = {};
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let vars = get(i, 'root.request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ folderVariables[_var.name] = _var.value;
+ }
+ });
+ } else {
+ const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ requestVariables[_var.name] = _var.value;
+ }
+ });
+ }
+ }
+
+ request.collectionVariables = collectionVariables;
+ request.folderVariables = folderVariables;
+ request.requestVariables = requestVariables;
+
+ if(request?.vars) {
+ request.vars.req = Array.from(reqVars, ([name, value]) => ({
+ name,
+ value,
+ enabled: true,
+ type: 'request'
+ }));
+ }
+
+ let resVars = new Map();
+ let collectionResponseVars = get(collection, 'root.request.vars.res', []);
+ collectionResponseVars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let vars = get(i, 'root.request.vars.res', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ } else {
+ const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ }
+ }
+
+ if(request?.vars) {
+ request.vars.res = Array.from(resVars, ([name, value]) => ({
+ name,
+ value,
+ enabled: true,
+ type: 'response'
+ }));
+ }
+};
+
+const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
+ let collectionPreReqScript = get(collection, 'root.request.script.req', '');
+ let collectionPostResScript = get(collection, 'root.request.script.res', '');
+ let collectionTests = get(collection, 'root.request.tests', '');
+
+ let combinedPreReqScript = [];
+ let combinedPostResScript = [];
+ let combinedTests = [];
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let preReqScript = get(i, 'root.request.script.req', '');
+ if (preReqScript && preReqScript.trim() !== '') {
+ combinedPreReqScript.push(preReqScript);
+ }
+
+ let postResScript = get(i, 'root.request.script.res', '');
+ if (postResScript && postResScript.trim() !== '') {
+ combinedPostResScript.push(postResScript);
+ }
+
+ let tests = get(i, 'root.request.tests', '');
+ if (tests && tests?.trim?.() !== '') {
+ combinedTests.push(tests);
+ }
+ }
+ }
+
+ request.script.req = compact([collectionPreReqScript, ...combinedPreReqScript, request?.script?.req || '']).join(os.EOL);
+
+ if (scriptFlow === 'sequential') {
+ request.script.res = compact([collectionPostResScript, ...combinedPostResScript, request?.script?.res || '']).join(os.EOL);
+ } else {
+ request.script.res = compact([request?.script?.res || '', ...combinedPostResScript.reverse(), collectionPostResScript]).join(os.EOL);
+ }
+
+ if (scriptFlow === 'sequential') {
+ request.tests = compact([collectionTests, ...combinedTests, request?.tests || '']).join(os.EOL);
+ } else {
+ request.tests = compact([request?.tests || '', ...combinedTests.reverse(), collectionTests]).join(os.EOL);
+ }
+};
+
+const findItem = (items = [], pathname) => {
+ return find(items, (i) => i.pathname === pathname);
+};
+
+const findItemInCollection = (collection, pathname) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return findItem(flattenedItems, pathname);
+};
+
+const findParentItemInCollection = (collection, pathname) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return find(flattenedItems, (item) => {
+ return item.items && find(item.items, (i) => i.pathname === pathname);
+ });
+};
+
+const flattenItems = (items = []) => {
+ const flattenedItems = [];
+
+ const flatten = (itms, flattened) => {
+ each(itms, (i) => {
+ flattened.push(i);
+
+ if (i.items && i.items.length) {
+ flatten(i.items, flattened);
+ }
+ });
+ };
+
+ flatten(items, flattenedItems);
+
+ return flattenedItems;
+};
+
+const getTreePathFromCollectionToItem = (collection, _item) => {
+ let path = [];
+ let item = findItemInCollection(collection, _item.pathname);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item.pathname);
+ }
+ return path;
+};
+
+const mergeAuth = (collection, request, requestTreePath) => {
+ let collectionAuth = collection?.root?.request?.auth || { mode: 'none' };
+ let effectiveAuth = collectionAuth;
+
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ const folderAuth = i?.root?.request?.auth;
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveAuth = folderAuth;
+ }
+ }
+ }
+
+ if (request.auth && request.auth.mode === 'inherit') {
+ request.auth = effectiveAuth;
+ }
+}
+
+const getAllRequestsInFolder = (folderItems = [], recursive = true) => {
+ let requests = [];
+
+ if (folderItems && folderItems.length) {
+ folderItems.forEach((item) => {
+ if (item.type !== 'folder') {
+ requests.push(item);
+ } else {
+ if (recursive) {
+ requests = requests.concat(getAllRequestsInFolder(item.items, recursive));
+ }
+ }
+ });
+ }
+ return requests;
+};
+
+const getAllRequestsAtFolderRoot = (folderItems = []) => {
+ return getAllRequestsInFolder(folderItems, false);
+}
+
+/**
+ * Safe write file implementation to handle errors
+ * @param {string} filePath - Path to write file
+ * @param {string} content - Content to write
+ */
+const safeWriteFileSync = (filePath, content) => {
+ try {
+ fs.writeFileSync(filePath, content, { encoding: 'utf8' });
+ } catch (error) {
+ console.error(`Error writing file ${filePath}:`, error);
+ }
+};
+
+/**
+ * Creates a Bruno collection directory structure from a Bruno collection object
+ *
+ * @param {Object} collection - The Bruno collection object
+ * @param {string} dirPath - The output directory path
+ */
+const createCollectionFromBrunoObject = async (collection, dirPath) => {
+ // Create bruno.json
+ const brunoConfig = {
+ version: '1',
+ name: collection.name,
+ type: 'collection',
+ ignore: ['node_modules', '.git']
+ };
+
+ fs.writeFileSync(
+ path.join(dirPath, 'bruno.json'),
+ JSON.stringify(brunoConfig, null, 2)
+ );
+
+ // Create collection.bru if root exists
+ if (collection.root) {
+ const collectionContent = await jsonToCollectionBru(collection.root);
+ fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
+ }
+
+ // Process environments
+ if (collection.environments && collection.environments.length) {
+ const envDirPath = path.join(dirPath, 'environments');
+ fs.mkdirSync(envDirPath, { recursive: true });
+
+ for (const env of collection.environments) {
+ const content = await envJsonToBruV2(env);
+ const filename = sanitizeName(`${env.name}.bru`);
+ fs.writeFileSync(path.join(envDirPath, filename), content);
+ }
+ }
+
+ // Process collection items
+ await processCollectionItems(collection.items, dirPath);
+
+ return dirPath;
+};
+
+/**
+ * Recursively processes collection items to create files and folders
+ *
+ * @param {Array} items - Collection items
+ * @param {string} currentPath - Current directory path
+ */
+const processCollectionItems = async (items = [], currentPath) => {
+ for (const item of items) {
+ if (item.type === 'folder') {
+ // Create folder
+ let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
+ const folderPath = path.join(currentPath, sanitizedFolderName);
+ fs.mkdirSync(folderPath, { recursive: true });
+
+ // Create folder.bru file if root exists
+ if (item?.root?.meta?.name) {
+ const folderBruFilePath = path.join(folderPath, 'folder.bru');
+ if (item.seq) {
+ item.root.meta.seq = item.seq;
+ }
+ const folderContent = await jsonToCollectionBru(
+ item.root,
+ true
+ );
+ safeWriteFileSync(folderBruFilePath, folderContent);
+ }
+
+ // Process folder items recursively
+ if (item.items && item.items.length) {
+ await processCollectionItems(item.items, folderPath);
+ }
+ } else if (['http-request', 'graphql-request'].includes(item.type)) {
+ // Create request file
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
+ if (!sanitizedFilename.endsWith('.bru')) {
+ sanitizedFilename += '.bru';
+ }
+
+ // Convert JSON to BRU format based on the item type
+ let type = item.type === 'http-request' ? 'http' : 'graphql';
+ const bruJson = {
+ meta: {
+ name: item.name,
+ type: type,
+ seq: typeof item.seq === 'number' ? item.seq : 1
+ },
+ http: {
+ method: (item.request?.method || 'GET').toLowerCase(),
+ url: item.request?.url || '',
+ auth: item.request?.auth?.mode || 'none',
+ body: item.request?.body?.mode || 'none'
+ },
+ params: item.request?.params || [],
+ headers: item.request?.headers || [],
+ auth: item.request?.auth || {},
+ body: item.request?.body || {},
+ script: item.request?.script || {},
+ vars: {
+ req: item.request?.vars?.req || [],
+ res: item.request?.vars?.res || []
+ },
+ assertions: item.request?.assertions || [],
+ tests: item.request?.tests || '',
+ docs: item.request?.docs || ''
+ };
+
+ // Convert to BRU format and write to file
+ const content = await jsonToBruV2(bruJson);
+ safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
+ }
+ }
+};
+
+module.exports = {
+ createCollectionJsonFromPathname,
+ mergeHeaders,
+ mergeVars,
+ mergeScripts,
+ findItemInCollection,
+ getTreePathFromCollectionToItem,
+ createCollectionFromBrunoObject,
+ mergeAuth,
+ getAllRequestsInFolder,
+ getAllRequestsAtFolderRoot
+}
\ No newline at end of file
diff --git a/packages/bruno-cli/src/utils/common.js b/packages/bruno-cli/src/utils/common.js
index 704928022..7a2ae3bf2 100644
--- a/packages/bruno-cli/src/utils/common.js
+++ b/packages/bruno-cli/src/utils/common.js
@@ -1,3 +1,5 @@
+const iconv = require('iconv-lite');
+
const lpad = (str, width) => {
let paddedStr = str;
while (paddedStr.length < width) {
@@ -14,7 +16,34 @@ const rpad = (str, width) => {
return paddedStr;
};
+const parseDataFromResponse = (response, disableParsingResponseJson = false) => {
+ // Parse the charset from content type: https://stackoverflow.com/a/33192813
+ const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
+ const charsetValue = charsetMatch?.[1];
+ const dataBuffer = Buffer.from(response.data);
+ // Overwrite the original data for backwards compatibility
+ let data;
+ if (iconv.encodingExists(charsetValue)) {
+ data = iconv.decode(dataBuffer, charsetValue);
+ } else {
+ data = iconv.decode(dataBuffer, 'utf-8');
+ }
+ // Try to parse response to JSON, this can quietly fail
+ try {
+ // Filter out ZWNBSP character
+ // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
+ data = data.replace(/^\uFEFF/, '');
+ if (!disableParsingResponseJson) {
+ data = JSON.parse(data);
+ }
+ } catch { }
+
+ return { data, dataBuffer };
+};
+
module.exports = {
lpad,
- rpad
+ rpad,
+ parseDataFromResponse
};
diff --git a/packages/bruno-cli/src/utils/cookies.js b/packages/bruno-cli/src/utils/cookies.js
new file mode 100644
index 000000000..01a82316b
--- /dev/null
+++ b/packages/bruno-cli/src/utils/cookies.js
@@ -0,0 +1,103 @@
+const { Cookie, CookieJar } = require('tough-cookie');
+const each = require('lodash/each');
+const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
+
+const cookieJar = new CookieJar();
+
+const addCookieToJar = (setCookieHeader, requestUrl) => {
+ const cookie = Cookie.parse(setCookieHeader, { loose: true });
+ cookieJar.setCookieSync(cookie, requestUrl, {
+ ignoreError: true // silently ignore things like parse errors and invalid domains
+ });
+};
+
+const getCookiesForUrl = (url) => {
+ return cookieJar.getCookiesSync(url, {
+ secure: isPotentiallyTrustworthyOrigin(url)
+ });
+};
+
+const getCookieStringForUrl = (url) => {
+ const cookies = getCookiesForUrl(url);
+
+ if (!Array.isArray(cookies) || !cookies.length) {
+ return '';
+ }
+
+ const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
+
+ return validCookies.map((cookie) => cookie.cookieString()).join('; ');
+};
+
+const getDomainsWithCookies = () => {
+ return new Promise((resolve, reject) => {
+ const domainCookieMap = {};
+
+ cookieJar.store.getAllCookies((err, cookies) => {
+ if (err) {
+ return reject(err);
+ }
+
+ cookies.forEach((cookie) => {
+ if (!domainCookieMap[cookie.domain]) {
+ domainCookieMap[cookie.domain] = [cookie];
+ } else {
+ domainCookieMap[cookie.domain].push(cookie);
+ }
+ });
+
+ const domains = Object.keys(domainCookieMap);
+ const domainsWithCookies = [];
+
+ each(domains, (domain) => {
+ const cookies = domainCookieMap[domain];
+ const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
+
+ if (validCookies.length) {
+ domainsWithCookies.push({
+ domain,
+ cookies: validCookies,
+ cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
+ });
+ }
+ });
+
+ resolve(domainsWithCookies);
+ });
+ });
+};
+
+const deleteCookiesForDomain = (domain) => {
+ return new Promise((resolve, reject) => {
+ cookieJar.store.removeCookies(domain, null, (err) => {
+ if (err) {
+ return reject(err);
+ }
+
+ return resolve();
+ });
+ });
+};
+
+const saveCookies = (url, headers) => {
+ let setCookieHeaders = [];
+ if (headers['set-cookie']) {
+ setCookieHeaders = Array.isArray(headers['set-cookie'])
+ ? headers['set-cookie']
+ : [headers['set-cookie']];
+ for (let setCookieHeader of setCookieHeaders) {
+ if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
+ addCookieToJar(setCookieHeader, url);
+ }
+ }
+ }
+}
+
+module.exports = {
+ addCookieToJar,
+ getCookiesForUrl,
+ getCookieStringForUrl,
+ getDomainsWithCookies,
+ deleteCookiesForDomain,
+ saveCookies
+};
diff --git a/packages/bruno-cli/src/utils/filesystem.js b/packages/bruno-cli/src/utils/filesystem.js
index 4b066ac68..46aa6c797 100644
--- a/packages/bruno-cli/src/utils/filesystem.js
+++ b/packages/bruno-cli/src/utils/filesystem.js
@@ -29,7 +29,7 @@ const isFile = (filepath) => {
const isDirectory = (dirPath) => {
try {
- return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
+ return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
} catch (_) {
return false;
}
@@ -118,6 +118,46 @@ const getSubDirectories = (dir) => {
}
};
+/**
+ * Sanitizes a filename to make it safe for filesystem operations
+ *
+ * @param {string} name - The name to sanitize
+ * @returns {string} - The sanitized name
+ */
+const sanitizeName = (name) => {
+ if (!name) return '';
+
+ const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
+ return name
+ .replace(invalidCharacters, '-') // replace invalid characters with hyphens
+ .replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
+ .replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
+};
+
+/**
+ * Validates if a name is valid for the filesystem
+ *
+ * @param {string} name - The name to validate
+ * @returns {boolean} - True if the name is valid, false otherwise
+ */
+const validateName = (name) => {
+ if (!name) return false;
+
+ const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
+ const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
+ const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
+ const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
+
+ if (name.length > 255) return false; // max name length
+ if (reservedDeviceNames.test(name)) return false; // windows reserved names
+
+ return (
+ firstCharacter.test(name) &&
+ middleCharacters.test(name) &&
+ lastCharacter.test(name)
+ );
+};
+
module.exports = {
exists,
isSymbolicLink,
@@ -131,5 +171,7 @@ module.exports = {
searchForFiles,
searchForBruFiles,
stripExtension,
- getSubDirectories
+ getSubDirectories,
+ sanitizeName,
+ validateName
};
diff --git a/packages/bruno-cli/src/utils/form-data.js b/packages/bruno-cli/src/utils/form-data.js
new file mode 100644
index 000000000..eab5d5824
--- /dev/null
+++ b/packages/bruno-cli/src/utils/form-data.js
@@ -0,0 +1,42 @@
+const { forEach } = require('lodash');
+const FormData = require('form-data');
+const fs = require('fs');
+const path = require('path');
+
+const createFormData = (data, collectionPath) => {
+ // make axios work in node using form data
+ // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
+ const form = new FormData();
+ forEach(data, (datum) => {
+ const { name, type, value, contentType } = datum;
+ let options = {};
+ if (contentType) {
+ options.contentType = contentType;
+ }
+ if (type === 'text') {
+ if (Array.isArray(value)) {
+ value.forEach((val) => form.append(name, val, options));
+ } else {
+ form.append(name, value, options);
+ }
+ return;
+ }
+
+ if (type === 'file') {
+ const filePaths = value || [];
+ filePaths.forEach((filePath) => {
+ let trimmedFilePath = filePath.trim();
+ if (!path.isAbsolute(trimmedFilePath)) {
+ trimmedFilePath = path.join(collectionPath, trimmedFilePath);
+ }
+ options.filename = path.basename(trimmedFilePath);
+ form.append(name, fs.createReadStream(trimmedFilePath), options);
+ });
+ }
+ });
+ return form;
+};
+
+module.exports = {
+ createFormData
+}
\ No newline at end of file
diff --git a/packages/bruno-cli/src/utils/proxy-util.js b/packages/bruno-cli/src/utils/proxy-util.js
index 729e03356..2a93d517e 100644
--- a/packages/bruno-cli/src/utils/proxy-util.js
+++ b/packages/bruno-cli/src/utils/proxy-util.js
@@ -79,7 +79,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
+
+const getSystemProxyEnvVariables = () => {
+ const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env;
+ return {
+ http_proxy: http_proxy || HTTP_PROXY,
+ https_proxy: https_proxy || HTTPS_PROXY,
+ no_proxy: no_proxy || NO_PROXY
+ };
+}
+
module.exports = {
shouldUseProxy,
- PatchedHttpsProxyAgent
+ PatchedHttpsProxyAgent,
+ getSystemProxyEnvVariables
};
diff --git a/packages/bruno-cli/tests/commands/run.spec.js b/packages/bruno-cli/tests/commands/run.spec.js
deleted file mode 100644
index 10cdf42b4..000000000
--- a/packages/bruno-cli/tests/commands/run.spec.js
+++ /dev/null
@@ -1,67 +0,0 @@
-const { describe, it, expect } = require('@jest/globals');
-
-const { printRunSummary } = require('../../src/commands/run');
-
-describe('printRunSummary', () => {
- // Suppress console.log output
- jest.spyOn(console, 'log').mockImplementation(() => {});
-
- it('should produce the correct summary for a successful run', () => {
- const results = [
- {
- testResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }],
- assertionResults: [{ status: 'pass' }, { status: 'pass' }],
- error: null
- },
- {
- testResults: [{ status: 'pass' }, { status: 'pass' }],
- assertionResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }],
- error: null
- }
- ];
-
- const summary = printRunSummary(results);
-
- expect(summary.totalRequests).toBe(2);
- expect(summary.passedRequests).toBe(2);
- expect(summary.failedRequests).toBe(0);
- expect(summary.totalAssertions).toBe(5);
- expect(summary.passedAssertions).toBe(5);
- expect(summary.failedAssertions).toBe(0);
- expect(summary.totalTests).toBe(5);
- expect(summary.passedTests).toBe(5);
- expect(summary.failedTests).toBe(0);
- });
-
- it('should produce the correct summary for a failed run', () => {
- const results = [
- {
- testResults: [{ status: 'fail' }, { status: 'pass' }, { status: 'pass' }],
- assertionResults: [{ status: 'pass' }, { status: 'fail' }],
- error: null
- },
- {
- testResults: [{ status: 'pass' }, { status: 'fail' }],
- assertionResults: [{ status: 'pass' }, { status: 'fail' }, { status: 'fail' }],
- error: null
- },
- {
- testResults: [],
- assertionResults: [],
- error: new Error('Request failed')
- }
- ];
-
- const summary = printRunSummary(results);
-
- expect(summary.totalRequests).toBe(3);
- expect(summary.passedRequests).toBe(2);
- expect(summary.failedRequests).toBe(1);
- expect(summary.totalAssertions).toBe(5);
- expect(summary.passedAssertions).toBe(2);
- expect(summary.failedAssertions).toBe(3);
- expect(summary.totalTests).toBe(5);
- expect(summary.passedTests).toBe(3);
- expect(summary.failedTests).toBe(2);
- });
-});
diff --git a/packages/bruno-cli/tests/reporters/html.spec.js b/packages/bruno-cli/tests/reporters/html.spec.js
deleted file mode 100644
index b45e57f41..000000000
--- a/packages/bruno-cli/tests/reporters/html.spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-const { describe, it, expect } = require('@jest/globals');
-const fs = require('fs');
-
-const makeHtmlOutput = require('../../src/reporters/html');
-
-describe('makeHtmlOutput', () => {
- beforeEach(() => {
- jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- it('should produce an html report', () => {
- const outputJson = {
- summary: {
- totalRequests: 1,
- passedRequests: 1,
- failedRequests: 1,
- totalAssertions: 1,
- passedAssertions: 1,
- failedAssertions: 1,
- totalTests: 1,
- passedTests: 1,
- failedTests: 1
- },
- results: [
- {
- description: 'description provided',
- suitename: 'Tests/Suite A',
- request: {
- method: 'GET',
- url: 'https://ima.test'
- },
- assertionResults: [
- {
- lhsExpr: 'res.status',
- rhsExpr: 'eq 200',
- status: 'pass'
- },
- {
- lhsExpr: 'res.status',
- rhsExpr: 'neq 200',
- status: 'fail',
- error: 'expected 200 to not equal 200'
- }
- ],
- runtime: 1.2345678
- },
- {
- request: {
- method: 'GET',
- url: 'https://imanother.test'
- },
- suitename: 'Tests/Suite B',
- testResults: [
- {
- lhsExpr: 'res.status',
- rhsExpr: 'eq 200',
- description: 'A test that passes',
- status: 'pass'
- },
- {
- description: 'A test that fails',
- status: 'fail',
- error: 'expected 200 to not equal 200',
- status: 'fail'
- }
- ],
- runtime: 2.3456789
- }
- ]
- };
-
- makeHtmlOutput(outputJson, '/tmp/testfile.html');
-
- const htmlReport = fs.writeFileSync.mock.calls[0][1];
- expect(htmlReport).toContain(JSON.stringify(outputJson, null, 2));
- });
-});
diff --git a/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js
new file mode 100644
index 000000000..8cab346cd
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js
@@ -0,0 +1,172 @@
+const path = require("node:path");
+const { describe, it, expect } = require('@jest/globals');
+const constants = require('../../src/constants');
+const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
+
+describe('create collection json from pathname', () => {
+ it("should throw an error when the pathname is not a valid bruno collection root", () => {
+ const invalidCollectionPathname = path.join(__dirname, './fixtures/collection-invalid');
+ jest.spyOn(console, 'error').mockImplementation(() => { });
+ let mockProcessExit = jest.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(code); });
+ try { createCollectionJsonFromPathname(invalidCollectionPathname); } catch { }
+ expect(mockProcessExit).toHaveBeenCalledWith(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
+ jest.restoreAllMocks();
+ })
+
+ it("creates a bruno collection json from the collection bru files", () => {
+ const collectionPathname = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
+ const outputCollectionJson = createCollectionJsonFromPathname(collectionPathname);
+
+ let c = outputCollectionJson;
+ expect(c).toBeDefined();
+
+ /* collection bruno.json */
+ expect(c).toHaveProperty('brunoConfig.version', "1");
+ expect(c).toHaveProperty('brunoConfig.name', 'collection');
+ expect(c).toHaveProperty('brunoConfig.type', 'collection');
+ expect(c).toHaveProperty('brunoConfig.ignore', ["node_modules", ".git"]);
+ expect(c).toHaveProperty('brunoConfig.proxy.enabled', false);
+ expect(c).toHaveProperty('brunoConfig.proxy.protocol', 'http');
+ expect(c).toHaveProperty('brunoConfig.proxy.hostname', '');
+ expect(c).toHaveProperty('brunoConfig.proxy.port', 3000);
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.enabled', false);
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.username', '');
+ expect(c).toHaveProperty('brunoConfig.proxy.auth.password', '');
+ expect(c).toHaveProperty('brunoConfig.proxy.bypassProxy', '');
+ expect(c).toHaveProperty('brunoConfig.scripts.moduleWhitelist', ['crypto', 'buffer']);
+ expect(c).toHaveProperty('brunoConfig.scripts.filesystemAccess.allow', true);
+ expect(c).toHaveProperty('brunoConfig.clientCertificates.enabled', true);
+ expect(c).toHaveProperty('brunoConfig.clientCertificates.certs', []);
+
+ /* collection pathname */
+ expect(c).toHaveProperty('pathname', collectionPathname);
+
+ /* collection root */
+ // headers
+ expect(c).toHaveProperty('root.request.headers[0].name', 'collection_header');
+ expect(c).toHaveProperty('root.request.headers[0].value', 'collection_header_value');
+ expect(c).toHaveProperty('root.request.headers[0].enabled', true);
+ // auth
+ expect(c).toHaveProperty('root.request.auth.mode', 'basic');
+ expect(c).toHaveProperty('root.request.auth.basic.username', 'username');
+ expect(c).toHaveProperty('root.request.auth.basic.password', 'password');
+ // pre-request scripts
+ expect(c).toHaveProperty('root.request.script.req', 'const collectionPreRequestScript = true;');
+ // collection root - post-response scripts
+ expect(c).toHaveProperty('root.request.script.res', 'const collectionPostResponseScript = true;');
+ // pre-request vars
+ expect(c).toHaveProperty('root.request.vars.req[0].name', 'collection_pre_var');
+ expect(c).toHaveProperty('root.request.vars.req[0].value', 'collection_pre_var_value');
+ expect(c).toHaveProperty('root.request.vars.req[0].enabled', true);
+ // post-response vars
+ expect(c).toHaveProperty('root.request.vars.res[0].name', 'collection_post_var');
+ expect(c).toHaveProperty('root.request.vars.res[0].value', 'collection_post_var_value');
+ expect(c).toHaveProperty('root.request.vars.res[0].enabled', true);
+ // tests
+ expect(c).toHaveProperty('root.request.tests', 'test(\"collection level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
+
+ /* collection items names and sequences */
+ // /folder_2
+ expect(c).toHaveProperty('items[0].type', 'folder');
+ expect(c).toHaveProperty('items[0].name', 'folder_2');
+ expect(c).toHaveProperty('items[0].seq', 1);
+
+ // /folder_2/request_1
+ expect(c).toHaveProperty('items[0].items[0].name', 'request_1');
+ expect(c).toHaveProperty('items[0].items[0].seq', 1);
+
+ // /folder_2/request_3
+ expect(c).toHaveProperty('items[0].items[1].name', 'request_3');
+ expect(c).toHaveProperty('items[0].items[1].seq', 2);
+
+ // /folder_2/request_2
+ expect(c).toHaveProperty('items[0].items[2].name', 'request_2');
+ expect(c).toHaveProperty('items[0].items[2].seq', 3);
+
+ // /folder_1
+ expect(c).toHaveProperty('items[1].type', 'folder');
+ expect(c).toHaveProperty('items[1].name', 'folder_1');
+ expect(c).toHaveProperty('items[1].seq', 5);
+
+ // /folder_1/folder_2
+ expect(c).toHaveProperty('items[1].items[0].name', 'folder_2');
+ expect(c).toHaveProperty('items[1].items[0].seq', 1);
+
+ // /folder_1/folder_2/request_3
+ expect(c).toHaveProperty('items[1].items[0].items[0].name', 'request_3');
+ expect(c).toHaveProperty('items[1].items[0].items[0].seq', 1);
+
+ // /folder_1/folder_2/request_1
+ expect(c).toHaveProperty('items[1].items[0].items[1].name', 'request_1');
+ expect(c).toHaveProperty('items[1].items[0].items[1].seq', 2);
+
+ // /folder_1/folder_2/request_2
+ expect(c).toHaveProperty('items[1].items[0].items[2].name', 'request_2');
+ expect(c).toHaveProperty('items[1].items[0].items[2].seq', 3);
+
+ // /folder_1/folder_1
+ expect(c).toHaveProperty('items[1].items[1].name', 'folder_1');
+ expect(c).toHaveProperty('items[1].items[1].seq', 2);
+
+ // /folder_1/folder_1/request_3
+ expect(c).toHaveProperty('items[1].items[1].items[0].name', 'request_3');
+ expect(c).toHaveProperty('items[1].items[1].items[0].seq', 1);
+
+ // /folder_1/folder_1/request_2
+ expect(c).toHaveProperty('items[1].items[1].items[1].name', 'request_2');
+ expect(c).toHaveProperty('items[1].items[1].items[1].seq', 2);
+
+ // /folder_1/folder_1/request_1
+ expect(c).toHaveProperty('items[1].items[1].items[2].name', 'request_1');
+ expect(c).toHaveProperty('items[1].items[1].items[2].seq', 3);
+
+ // /folder_1/request_1
+ expect(c).toHaveProperty('items[1].items[2].name', 'request_1');
+ expect(c).toHaveProperty('items[1].items[2].seq', 3);
+
+ // /folder_1/request_3
+ expect(c).toHaveProperty('items[1].items[3].name', 'request_3');
+ expect(c).toHaveProperty('items[1].items[3].seq', 4);
+
+ // /folder_1/request_2
+ expect(c).toHaveProperty('items[1].items[4].name', 'request_2');
+ expect(c).toHaveProperty('items[1].items[4].seq', 5);
+
+ // /request_2
+ expect(c).toHaveProperty('items[2].name', 'request_3');
+ expect(c).toHaveProperty('items[2].seq', 2);
+
+ // /request_3
+ expect(c).toHaveProperty('items[3].name', 'request_1');
+ expect(c).toHaveProperty('items[3].seq', 3);
+
+ // /request_4
+ expect(c).toHaveProperty('items[4].name', 'request_2');
+ expect(c).toHaveProperty('items[4].seq', 4);
+
+ /* collection request item - /request_4 */
+ // /request_4
+ // headers
+ expect(c).toHaveProperty('items[4].request.headers[0].name', 'request_header');
+ expect(c).toHaveProperty('items[4].request.headers[0].value', 'request_header_value');
+ expect(c).toHaveProperty('items[4].request.headers[0].enabled', true);
+ // auth
+ expect(c).toHaveProperty('items[4].request.auth.mode', 'basic');
+ expect(c).toHaveProperty('items[4].request.auth.basic.username', 'username');
+ expect(c).toHaveProperty('items[4].request.auth.basic.password', 'password');
+ // pre-request scripts
+ expect(c).toHaveProperty('items[4].request.script.req', 'const requestPreRequestScript = true;');
+ // request items[4] - post-response scripts
+ expect(c).toHaveProperty('items[4].request.script.res', 'const requestPostResponseScript = true;');
+ // pre-request vars
+ expect(c).toHaveProperty('items[4].request.vars.req[0].name', 'request_pre_var');
+ expect(c).toHaveProperty('items[4].request.vars.req[0].value', 'request_pre_var_value');
+ expect(c).toHaveProperty('items[4].request.vars.req[0].enabled', true);
+ // post-response vars
+ expect(c).toHaveProperty('items[4].request.vars.res[0].name', 'request_post_var');
+ expect(c).toHaveProperty('items[4].request.vars.res[0].value', 'request_post_var_value');
+ expect(c).toHaveProperty('items[4].request.vars.res[0].enabled', true);
+ // tests
+ expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json
new file mode 100644
index 000000000..366f84472
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json
@@ -0,0 +1,31 @@
+{
+ "version": "1",
+ "name": "collection",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ],
+ "proxy": {
+ "enabled": false,
+ "protocol": "http",
+ "hostname": "",
+ "port": 3000,
+ "auth": {
+ "enabled": false,
+ "username": "",
+ "password": ""
+ },
+ "bypassProxy": ""
+ },
+ "scripts": {
+ "moduleWhitelist": ["crypto", "buffer"],
+ "filesystemAccess": {
+ "allow": true
+ }
+ },
+ "clientCertificates": {
+ "enabled": true,
+ "certs": []
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru
new file mode 100644
index 000000000..bdfbfc430
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/collection.bru
@@ -0,0 +1,38 @@
+headers {
+ collection_header: collection_header_value
+}
+
+auth {
+ mode: basic
+}
+
+auth:basic {
+ username: username
+ password: password
+}
+
+vars:pre-request {
+ collection_pre_var: collection_pre_var_value
+}
+
+vars:post-response {
+ collection_post_var: collection_post_var_value
+}
+
+script:pre-request {
+ const collectionPreRequestScript = true;
+}
+
+script:post-response {
+ const collectionPostResponseScript = true;
+}
+
+tests {
+ test("collection level script", function() {
+ expect("test").to.equal("test");
+ });
+}
+
+docs {
+ # docs
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru
new file mode 100644
index 000000000..fb30da65c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_1
+ seq: 5
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru
new file mode 100644
index 000000000..2b5e3cd2f
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_1
+ seq: 2
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru
new file mode 100644
index 000000000..b0b7e046e
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru
new file mode 100644
index 000000000..7953c4499
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_1/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru
new file mode 100644
index 000000000..674476e89
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_2
+ seq: 1
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru
new file mode 100644
index 000000000..c93c6cb37
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru
new file mode 100644
index 000000000..375cc9f6d
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru
new file mode 100644
index 000000000..7953c4499
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/folder_2/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru
new file mode 100644
index 000000000..7dac68aed
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 5
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru
new file mode 100644
index 000000000..8a818f66c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_1/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 4
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru
new file mode 100644
index 000000000..674476e89
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: folder_2
+ seq: 1
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru
new file mode 100644
index 000000000..b8fb205ed
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru
new file mode 100644
index 000000000..375cc9f6d
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_2.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_2
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru
new file mode 100644
index 000000000..a2582cf6c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/folder_2/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru
new file mode 100644
index 000000000..79ff1676a
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_1.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_1
+ type: http
+ seq: 3
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru
new file mode 100644
index 000000000..1aef2b30e
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_2.bru
@@ -0,0 +1,58 @@
+meta {
+ name: request_2
+ type: http
+ seq: 4
+}
+
+post {
+ url: https://echo.usebruno.com/:request_path_param?request_query_param=request_query_param_value
+ body: text
+ auth: basic
+}
+
+params:query {
+ request_query_param: request_query_param_value
+}
+
+params:path {
+ request_path_param: request_path_param_value
+}
+
+headers {
+ request_header: request_header_value
+}
+
+auth:basic {
+ username: username
+ password: password
+}
+
+body:text {
+ ping
+}
+
+vars:pre-request {
+ request_pre_var: request_pre_var_value
+}
+
+vars:post-response {
+ request_post_var: request_post_var_value
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:pre-request {
+ const requestPreRequestScript = true;
+}
+
+script:post-response {
+ const requestPostResponseScript = true;
+}
+
+tests {
+ test("request level script", function() {
+ expect("test").to.equal("test");
+ });
+}
diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru
new file mode 100644
index 000000000..a2582cf6c
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/request_3.bru
@@ -0,0 +1,11 @@
+meta {
+ name: request_3
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
diff --git a/packages/bruno-cli/tests/runner/prepare-request.spec.js b/packages/bruno-cli/tests/runner/prepare-request.spec.js
new file mode 100644
index 000000000..d532dcff1
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/prepare-request.spec.js
@@ -0,0 +1,522 @@
+const { describe, it, expect, beforeEach } = require('@jest/globals');
+const prepareRequest = require('../../src/runner/prepare-request');
+
+describe('prepare-request: prepareRequest', () => {
+ describe('Decomments request body', () => {
+ it('If request body is valid JSON', async () => {
+ const body = { mode: 'json', json: '{\n"test": "{{someVar}}" // comment\n}' };
+ const expected = `{
+\"test\": \"{{someVar}}\"
+}`;
+ const result = prepareRequest({ request: { body } });
+ expect(result.data).toEqual(expected);
+ });
+
+ it('If request body is not valid JSON', async () => {
+ const body = { mode: 'json', json: '{\n"test": {{someVar}} // comment\n}' };
+ const expected = `{
+\"test\": {{someVar}}
+}`;
+ const result = prepareRequest({ request: { body } });
+ 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.basicAuth).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('OAuth2 Authentication', () => {
+ it('If collection auth is OAuth2 with client credentials grant type', () => {
+ collection.root.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://auth.example.com/token',
+ clientId: 'test_client_id',
+ clientSecret: 'test_client_secret',
+ scope: 'read write',
+ credentialsPlacement: 'header',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+
+ expect(result.oauth2).toBeDefined();
+ expect(result.oauth2.grantType).toBe('client_credentials');
+ expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token');
+ expect(result.oauth2.clientId).toBe('test_client_id');
+ expect(result.oauth2.clientSecret).toBe('test_client_secret');
+ expect(result.oauth2.scope).toBe('read write');
+ expect(result.oauth2.credentialsPlacement).toBe('header');
+ expect(result.oauth2.tokenPlacement).toBe('header');
+ expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer');
+ expect(result.oauth2.tokenQueryKey).toBe('access_token');
+ });
+
+ it('If collection auth is OAuth2 with password grant type', () => {
+ collection.root.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'password',
+ accessTokenUrl: 'https://auth.example.com/token',
+ username: 'test_user',
+ password: 'test_password',
+ clientId: 'test_client_id',
+ clientSecret: 'test_client_secret',
+ scope: 'read write',
+ credentialsPlacement: 'body',
+ tokenPlacement: 'url',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+
+ expect(result.oauth2).toBeDefined();
+ expect(result.oauth2.grantType).toBe('password');
+ expect(result.oauth2.accessTokenUrl).toBe('https://auth.example.com/token');
+ expect(result.oauth2.username).toBe('test_user');
+ expect(result.oauth2.password).toBe('test_password');
+ expect(result.oauth2.clientId).toBe('test_client_id');
+ expect(result.oauth2.clientSecret).toBe('test_client_secret');
+ expect(result.oauth2.scope).toBe('read write');
+ expect(result.oauth2.credentialsPlacement).toBe('body');
+ expect(result.oauth2.tokenPlacement).toBe('url');
+ expect(result.oauth2.tokenHeaderPrefix).toBe('Bearer');
+ expect(result.oauth2.tokenQueryKey).toBe('access_token');
+ });
+ });
+
+ describe('AWS v4 Authentication', () => {
+ it('If collection auth is AWS v4', () => {
+ collection.root.request.auth = {
+ mode: 'awsv4',
+ awsv4: {
+ accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
+ secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ sessionToken: 'session-token',
+ service: 's3',
+ region: 'us-west-2',
+ profileName: 'default'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+ const expected = {
+ accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
+ secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ sessionToken: 'session-token',
+ service: 's3',
+ region: 'us-west-2',
+ profileName: 'default'
+ };
+ expect(result.awsv4config).toEqual(expected);
+ });
+ });
+
+ describe('NTLM Authentication', () => {
+ it('If collection auth is NTLM', () => {
+ collection.root.request.auth = {
+ mode: 'ntlm',
+ ntlm: {
+ username: 'testUser',
+ password: 'testPass123',
+ domain: 'testDomain'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+ const expected = {
+ username: 'testUser',
+ password: 'testPass123',
+ domain: 'testDomain'
+ };
+ expect(result.ntlmConfig).toEqual(expected);
+ });
+ });
+
+ describe('WSSE Authentication', () => {
+ it('If collection auth is WSSE', () => {
+ collection.root.request.auth = {
+ mode: 'wsse',
+ wsse: {
+ username: 'testUser',
+ password: 'testPass123'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+ expect(result.headers).toHaveProperty('X-WSSE');
+ expect(result.headers['X-WSSE']).toContain('UsernameToken Username="testUser"');
+ expect(result.headers['X-WSSE']).toContain('PasswordDigest="');
+ expect(result.headers['X-WSSE']).toContain('Nonce="');
+ expect(result.headers['X-WSSE']).toContain('Created="');
+ });
+ });
+
+ describe('Digest Authentication', () => {
+ it('If collection auth is digest auth', () => {
+ collection.root.request.auth = {
+ mode: 'digest',
+ digest: {
+ username: 'testUser',
+ password: 'testPass123'
+ }
+ };
+
+ const result = prepareRequest(item, collection);
+
+ const expected = {
+ username: 'testUser',
+ password: 'testPass123'
+ };
+ expect(result.digestConfig).toEqual(expected);
+ });
+ });
+
+ 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();
+ });
+ });
+ });
+
+ describe('Properly maps request-level auth', () => {
+ let item;
+
+ beforeEach(() => {
+ item = {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ headers: [],
+ params: [],
+ url: 'https://usebruno.com',
+ auth: {
+ mode: 'basic' // Will be overridden in each test
+ },
+ script: {
+ req: 'console.log("Pre Request")',
+ res: 'console.log("Post Response")'
+ }
+ }
+ };
+ });
+
+ describe('API Key Authentication', () => {
+ it('If request auth is apikey in header', () => {
+ item.request.auth = {
+ mode: "apikey",
+ apikey: {
+ key: "x-api-key",
+ value: "{{apiKey}}",
+ placement: "header"
+ }
+ };
+
+ const result = prepareRequest(item);
+ expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
+ });
+
+ it('If request auth is apikey in header and request has existing headers', () => {
+ item.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);
+ expect(result.headers).toHaveProperty('Content-Type', 'application/json');
+ expect(result.headers).toHaveProperty('x-api-key', '{{apiKey}}');
+ });
+
+ it('If request auth is apikey in query parameters', () => {
+ item.request.auth = {
+ mode: "apikey",
+ apikey: {
+ key: "x-api-key",
+ value: "{{apiKey}}",
+ placement: "queryparams"
+ }
+ };
+
+ const urlObj = new URL(item.request.url);
+ urlObj.searchParams.set(item.request.auth.apikey.key, item.request.auth.apikey.value);
+
+ const expected = urlObj.toString();
+ const result = prepareRequest(item);
+ expect(result.url).toEqual(expected);
+ });
+ });
+
+ describe('Basic Authentication', () => {
+ it('If request auth is basic auth', () => {
+ item.request.auth = {
+ mode: 'basic',
+ basic: {
+ username: 'testUser',
+ password: 'testPass123'
+ }
+ };
+
+ const result = prepareRequest(item);
+ const expected = { username: 'testUser', password: 'testPass123' };
+ expect(result.basicAuth).toEqual(expected);
+ });
+ });
+
+ describe('Bearer Token Authentication', () => {
+ it('If request auth is bearer token', () => {
+ item.request.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'token123'
+ }
+ };
+
+ const result = prepareRequest(item);
+ expect(result.headers).toHaveProperty('Authorization', 'Bearer token123');
+ });
+
+ it('If request auth is bearer token and request has existing headers', () => {
+ item.request.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'token123'
+ }
+ };
+
+ item.request.headers.push({ name: 'Content-Type', value: 'application/json', enabled: true });
+
+ const result = prepareRequest(item);
+ expect(result.headers).toHaveProperty('Authorization', 'Bearer token123');
+ expect(result.headers).toHaveProperty('Content-Type', 'application/json');
+ });
+ });
+
+ describe('AWS v4 Authentication', () => {
+ it('If request auth is AWS v4', () => {
+ item.request.auth = {
+ mode: 'awsv4',
+ awsv4: {
+ accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
+ secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ sessionToken: 'request-session-token',
+ service: 'dynamodb',
+ region: 'us-east-1',
+ profileName: 'dev'
+ }
+ };
+
+ const result = prepareRequest(item);
+ const expected = {
+ accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
+ secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ sessionToken: 'request-session-token',
+ service: 'dynamodb',
+ region: 'us-east-1',
+ profileName: 'dev'
+ };
+ expect(result.awsv4config).toEqual(expected);
+ });
+ });
+
+ describe('NTLM Authentication', () => {
+ it('If request auth is NTLM', () => {
+ item.request.auth = {
+ mode: 'ntlm',
+ ntlm: {
+ username: 'testUser',
+ password: 'testPass123',
+ domain: 'testDomain'
+ }
+ };
+
+ const result = prepareRequest(item);
+ const expected = {
+ username: 'testUser',
+ password: 'testPass123',
+ domain: 'testDomain'
+ };
+ expect(result.ntlmConfig).toEqual(expected);
+ });
+ });
+
+ describe('WSSE Authentication', () => {
+ it('If request auth is WSSE', () => {
+ item.request.auth = {
+ mode: 'wsse',
+ wsse: {
+ username: 'requestUser',
+ password: 'requestPass'
+ }
+ };
+
+ const result = prepareRequest(item);
+ expect(result.headers).toHaveProperty('X-WSSE');
+ expect(result.headers['X-WSSE']).toContain('UsernameToken Username="requestUser"');
+ expect(result.headers['X-WSSE']).toContain('PasswordDigest="');
+ expect(result.headers['X-WSSE']).toContain('Nonce="');
+ expect(result.headers['X-WSSE']).toContain('Created="');
+ });
+ });
+
+ describe('Digest Authentication', () => {
+ it('If request auth is digest auth', () => {
+ item.request.auth = {
+ mode: 'digest',
+ digest: {
+ username: 'requestUser',
+ password: 'requestPass123'
+ }
+ };
+
+ const result = prepareRequest(item);
+ const expected = {
+ username: 'requestUser',
+ password: 'requestPass123'
+ };
+ expect(result.digestConfig).toEqual(expected);
+ });
+ });
+ });
+});
diff --git a/packages/bruno-common/babel.config.js b/packages/bruno-common/babel.config.js
new file mode 100644
index 000000000..2f87e5c7c
--- /dev/null
+++ b/packages/bruno-common/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { modules: 'auto' }],
+ '@babel/preset-typescript',
+ ],
+};
diff --git a/packages/bruno-common/jest.config.js b/packages/bruno-common/jest.config.js
index a58c252f8..cd4a5f5ae 100644
--- a/packages/bruno-common/jest.config.js
+++ b/packages/bruno-common/jest.config.js
@@ -1,5 +1,9 @@
-/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
- preset: 'ts-jest',
+ transform: {
+ '^.+\\.(ts|js)$': 'babel-jest',
+ },
+ transformIgnorePatterns: [
+ '/node_modules/(?!(lodash-es)/)',
+ ],
testEnvironment: 'node'
};
diff --git a/packages/bruno-common/package.json b/packages/bruno-common/package.json
index d8e598420..a29f586ec 100644
--- a/packages/bruno-common/package.json
+++ b/packages/bruno-common/package.json
@@ -5,6 +5,18 @@
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "require": "./dist/cjs/index.js",
+ "import": "./dist/esm/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./runner": {
+ "require": "./dist/runner/cjs/index.js",
+ "import": "./dist/runner/esm/index.js",
+ "types": "./dist/runner/index.d.ts"
+ }
+ },
"files": [
"dist",
"src",
@@ -13,21 +25,30 @@
"scripts": {
"clean": "rimraf dist",
"test": "jest",
+ "test:watch": "jest --watch",
"prebuild": "npm run clean",
- "build": "rollup -c",
+ "build": "rollup -c rollup.config.js",
+ "watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
+ "@babel/preset-env": "^7.26.9",
+ "@babel/preset-typescript": "^7.27.0",
+ "@faker-js/faker": "^9.7.0",
+ "@jest/globals": "^29.7.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
- "@rollup/plugin-typescript": "^9.0.2",
- "rollup": "3.2.5",
+ "@rollup/plugin-typescript": "^12.1.2",
+ "@types/jest": "^29.5.14",
+ "babel-jest": "^29.7.0",
+ "moment": "^2.29.4",
+ "rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
- "typescript": "^4.8.4"
+ "typescript": "^5.8.3"
},
"overrides": {
- "rollup": "3.2.5"
+ "rollup": "3.29.5"
}
}
diff --git a/packages/bruno-common/rollup.config.js b/packages/bruno-common/rollup.config.js
index 51aedecb6..8eec5127f 100644
--- a/packages/bruno-common/rollup.config.js
+++ b/packages/bruno-common/rollup.config.js
@@ -7,34 +7,53 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require('./package.json');
-module.exports = [
- {
- input: 'src/index.ts',
- output: [
- {
- file: packageJson.main,
- format: 'cjs',
- sourcemap: true
- },
- {
- file: packageJson.module,
- format: 'esm',
- sourcemap: true
+function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
+ return [
+ {
+ input,
+ output: [
+ {
+ file: cjsOutput,
+ format: 'cjs',
+ sourcemap: true
+ },
+ {
+ file: esmOutput,
+ format: 'esm',
+ sourcemap: true
+ }
+ ],
+ plugins: [
+ peerDepsExternal(),
+ nodeResolve(),
+ commonjs(),
+ typescript({
+ tsconfig: './tsconfig.json',
+ include: [inputDir]
+ }),
+ terser()
+ ],
+ treeshake: {
+ moduleSideEffects: false
}
- ],
- plugins: [
- peerDepsExternal(),
- nodeResolve({
- extensions: ['.css']
- }),
- commonjs(),
- typescript({ tsconfig: './tsconfig.json' }),
- terser()
- ]
- },
- {
- input: 'dist/esm/index.d.ts',
- output: [{ file: 'dist/index.d.ts', format: 'esm' }],
- plugins: [dts.default()]
- }
+ }
+ ];
+}
+
+// todo: configure declarations
+module.exports = [
+ // Main package build
+ ...createBuildConfig({
+ inputDir: 'src/**/*',
+ input: 'src/index.ts',
+ cjsOutput: packageJson.main,
+ esmOutput: packageJson.module
+ }),
+ // reports/html
+ ...createBuildConfig({
+ inputDir: 'src/runner/**/*',
+ input: 'src/runner/index.ts',
+ cjsOutput: 'dist/runner/cjs/index.js',
+ esmOutput: 'dist/runner/esm/index.js'
+ })
];
diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts
index 04a709c57..7d3b6e72d 100644
--- a/packages/bruno-common/src/index.ts
+++ b/packages/bruno-common/src/index.ts
@@ -1,5 +1 @@
-import interpolate from './interpolate';
-
-export default {
- interpolate
-};
+export { default as interpolate } from './interpolate';
diff --git a/packages/bruno-common/src/interpolate/index.spec.ts b/packages/bruno-common/src/interpolate/index.spec.ts
index 9779021ee..925886dcd 100644
--- a/packages/bruno-common/src/interpolate/index.spec.ts
+++ b/packages/bruno-common/src/interpolate/index.spec.ts
@@ -1,5 +1,5 @@
import interpolate from './index';
-
+import moment from 'moment';
describe('interpolate', () => {
it('should replace placeholders with values from the object', () => {
const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
@@ -41,7 +41,7 @@ describe('interpolate', () => {
Hi, I am {{user.full_name}},
I am {{user.age}} years old.
My favorite food is {{user.fav-food[0]}} and {{user.fav-food[1]}}.
- I like attention: {{user.want.attention}}
+ I like attention: {{user['want.attention']}}
`;
const expectedStr = `
Hi, I am Bruno,
@@ -67,19 +67,21 @@ describe('interpolate', () => {
expect(result).toBe('Hello, my name is {{ user.name }} and I am 4 years old');
});
- it('should give precedence to the last key in case of duplicates', () => {
- const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
+ test('should give precedence to the last key in case of duplicates (not at the top level)', () => {
+ const inputString = `Hello, my name is {{data['user.name']}} and {{data.user.name}} I am {{data.user.age}} years old`;
const inputObject = {
- 'user.name': 'Bruno',
- user: {
- name: 'Not Bruno',
- age: 4
+ data: {
+ 'user.name': 'Bruno',
+ user: {
+ name: 'Not _Bruno_',
+ age: 4
+ }
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Not Bruno and I am 4 years old');
+ expect(result).toBe('Hello, my name is Bruno and Not _Bruno_ I am 4 years old');
});
});
@@ -169,3 +171,403 @@ describe('interpolate - value edge cases', () => {
expect(result).toBe(inputString);
});
});
+
+describe('interpolate - recursive', () => {
+ it('should replace placeholders with 1 level of recursion with values from the object', () => {
+ const inputString = '{{user.message}}';
+ const inputObject = {
+ 'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
+ 'user.name': 'Bruno',
+ user: {
+ age: 4
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
+ });
+
+ it('should replace placeholders with 2 level of recursion with values from the object', () => {
+ const inputString = '{{user.message}}';
+ const inputObject = {
+ 'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
+ 'user.name': 'Bruno {{user.lastName}}',
+ 'user.lastName': 'Dog',
+ user: {
+ age: 4
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
+ });
+
+ it('should replace placeholders with 3 level of recursion with values from the object', () => {
+ const inputString = '{{user.message}}';
+ const inputObject = {
+ 'user.message': 'Hello, my name is {{user.full_name}} and I am {{user.age}} years old',
+ 'user.full_name': '{{user.name}}',
+ 'user.name': 'Bruno {{user.lastName}}',
+ 'user.lastName': 'Dog',
+ user: {
+ age: 4
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
+ });
+
+ it('should handle missing values with 1 level of recursion by leaving the placeholders unchanged using {{}} as delimiters', () => {
+ const inputString = '{{user.message}}';
+ const inputObject = {
+ 'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
+ user: {
+ age: 4
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Hello, my name is {{user.name}} and I am 4 years old');
+ });
+
+ it('should handle all valid keys with 1 level of recursion', () => {
+ const message = `
+ Hi, I am {{user.full_name}},
+ I am {{user.age}} years old.
+ My favorite food is {{user.fav-food[0]}} and {{user.fav-food[1]}}.
+ I like attention: {{user['want.attention']}}
+`;
+ const inputObject = {
+ user: {
+ message,
+ full_name: 'Bruno',
+ age: 4,
+ 'fav-food': ['egg', 'meat'],
+ 'want.attention': true
+ }
+ };
+
+ const inputStr = '{{user.message}}';
+ const expectedStr = `
+ Hi, I am Bruno,
+ I am 4 years old.
+ My favorite food is egg and meat.
+ I like attention: true
+`;
+ const result = interpolate(inputStr, inputObject);
+ expect(result).toBe(expectedStr);
+ });
+
+ it('should not process 1 level of cycle recursion with values from the object', () => {
+ const inputString = '{{recursion}}';
+ const inputObject = {
+ recursion: '{{recursion}}'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('{{recursion}}');
+ });
+
+ it('should not process 2 level of cycle recursion with values from the object', () => {
+ const inputString = '{{recursion}}';
+ const inputObject = {
+ recursion: '{{recursion2}}',
+ recursion2: '{{recursion}}'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('{{recursion2}}');
+ });
+
+ it('should not process 3 level of cycle recursion with values from the object', () => {
+ const inputString = '{{recursion}}';
+ const inputObject = {
+ recursion: '{{recursion2}}',
+ recursion2: '{{recursion3}}',
+ recursion3: '{{recursion}}'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('{{recursion3}}');
+ });
+
+ it('should replace repeated placeholders with 1 level of recursion with values from the object', () => {
+ const inputString = '{{repeated}}';
+ const inputObject = {
+ repeated: '{{repeated2}} {{repeated2}}',
+ repeated2: 'repeated2'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe(new Array(2).fill('repeated2').join(' '));
+ });
+
+ it('should replace repeated placeholders with 2 level of recursion with values from the object', () => {
+ const inputString = '{{repeated}}';
+ const inputObject = {
+ repeated: '{{repeated2}} {{repeated2}}',
+ repeated2: '{{repeated3}} {{repeated3}} {{repeated3}}',
+ repeated3: 'repeated3'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe(new Array(6).fill('repeated3').join(' '));
+ });
+
+ it('should replace repeated placeholders with 3 level of recursion with values from the object', () => {
+ const inputString = '{{repeated}}';
+ const inputObject = {
+ repeated: '{{repeated2}} {{repeated2}}',
+ repeated2: '{{repeated3}} {{repeated3}} {{repeated3}}',
+ repeated3: '{{repeated4}} {{repeated4}} {{repeated4}} {{repeated4}}',
+ repeated4: 'repeated4'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe(new Array(24).fill('repeated4').join(' '));
+ });
+
+ it('should replace multiple interdependent variables in the same input string', () => {
+ const inputString = `{
+ "x": "{{v2}} {{v1}}"
+ }`;
+ const inputObject = {
+ foo: 'bar',
+ v1: '{{foo}}',
+ v2: '{{bar}}',
+ bar: 'baz'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe(`{
+ "x": "baz bar"
+ }`);
+ });
+});
+
+describe('interpolate - object handling', () => {
+ it('should stringify simple objects', () => {
+ const inputString = 'User: {{user}}';
+ const inputObject = {
+ 'user': { name: 'Bruno', age: 4 }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User: {"name":"Bruno","age":4}');
+ });
+
+ it('should stringify simple objects (dot notation)', () => {
+ const inputString = 'User: {{user.data}}';
+ const inputObject = {
+ 'user.data': { name: 'Bruno', age: 4 }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User: {"name":"Bruno","age":4}');
+ });
+
+ it('should stringify nested objects', () => {
+ const inputString = 'User: {{user}}';
+ const inputObject = {
+ 'user': {
+ name: 'Bruno',
+ age: 4,
+ preferences: {
+ food: ['egg', 'meat'],
+ toys: { favorite: 'ball' }
+ }
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User: {"name":"Bruno","age":4,"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}');
+ });
+
+ it('should stringify arrays', () => {
+ const inputString = 'User favorites: {{favorites}}';
+ const inputObject = {
+ favorites: ['egg', 'meat', 'treats']
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User favorites: ["egg","meat","treats"]');
+ });
+
+ it('should handle null values correctly', () => {
+ const inputString = 'User: {{user}}';
+ const inputObject = {
+ 'user': null
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User: null');
+ });
+
+ it('should handle objects with nested interpolation', () => {
+ const inputString = 'User: {{user}}';
+ const inputObject = {
+ 'user': {
+ name: 'Bruno',
+ message: '{{user.greeting}}'
+ },
+ 'user.greeting': 'Hello there!'
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('User: {"name":"Bruno","message":"Hello there!"}');
+ });
+
+ it('should handle objects within arrays', () => {
+ const inputString = 'Items: {{items}}';
+ const inputObject = {
+ 'items': [
+ { id: 1, name: 'Toy' },
+ { id: 2, name: 'Bone' },
+ { id: 3, name: 'Ball', colors: ['red', 'blue'] }
+ ]
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Items: [{"id":1,"name":"Toy"},{"id":2,"name":"Bone"},{"id":3,"name":"Ball","colors":["red","blue"]}]');
+ });
+});
+
+describe('interpolate - mock variable interpolation', () => {
+ it('should replace mock variables with generated values', () => {
+ const inputString = '{{$randomInt}}, {{$randomIP}}, {{$randomIPV4}}, {{$randomIPV6}}, {{$randomBoolean}}';
+
+ const result = interpolate(inputString, {});
+
+ // Validate the result using regex patterns
+ const randomIntPattern = /^\d+$/;
+ const randomIPPattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$|^(\d{1,3}\.){3}\d{1,3}$/;
+ const randomIPV4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
+ const randomIPV6Pattern = /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/;
+ const randomBooleanPattern = /^(true|false)$/;
+
+ const [randomInt, randomIP, randomIPV4, randomIPV6, randomBoolean] = result.split(', ');
+
+ expect(randomIntPattern.test(randomInt)).toBe(true);
+ expect(randomIPPattern.test(randomIP)).toBe(true);
+ expect(randomIPV4Pattern.test(randomIPV4)).toBe(true);
+ expect(randomIPV6Pattern.test(randomIPV6)).toBe(true);
+ expect(randomBooleanPattern.test(randomBoolean)).toBe(true);
+ });
+
+ it('should leave mock variables unchanged if no corresponding function exists', () => {
+ const inputString = 'Random number: {{$nonExistentMock}}';
+
+ const result = interpolate(inputString, {});
+
+ expect(result).toBe('Random number: {{$nonExistentMock}}');
+ });
+
+ it('should escape special characters in mock variable values and produce valid JSON when escapeJSONStrings is true', () => {
+ const inputString = '{"escapedValue": "{{$randomLoremParagraphs}}"}';
+
+ expect(() => {
+ const result = interpolate(inputString, {}, { escapeJSONStrings: true });
+ JSON.parse(result); // This should not throw an error
+ }).not.toThrow();
+ });
+
+ it('should not produce valid JSON when escapeJSONStrings is false', () => {
+ const inputString = '{"escapedValue": "{{$randomLoremParagraphs}}"}';
+
+ expect(() => {
+ const result = interpolate(inputString, {}, { escapeJSONStrings: false });
+ JSON.parse(result); // This should throw an error
+ }).toThrow();
+ });
+
+ it('should throw an error when producing invalid JSON regardless of escapeJSONStrings option', () => {
+ const inputString = '{"escapedValue": "{{$randomLoremParagraphs}}"}';
+
+ // Test without providing the options argument
+ expect(() => {
+ const result = interpolate(inputString, {});
+ JSON.parse(result); // This should throw an error
+ }).toThrow();
+
+ // Test with escapeJSONStrings explicitly set to false
+ expect(() => {
+ const result = interpolate(inputString, {}, { escapeJSONStrings: false });
+ JSON.parse(result); // This should throw an error
+ }).toThrow();
+ });
+});
+
+describe('interpolate - Date() handling', () => {
+ it('should interpolate Date() using JSON.stringify', () => {
+ const inputString = 'Date is {{date}}';
+ const inputObject = {
+ date: new Date("2025-04-17T15:33:41.117Z")
+ };
+
+ const jsonStringifiedDate = JSON.stringify(inputObject.date);
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Date is "2025-04-17T15:33:41.117Z"');
+ expect(result).toBe(`Date is ${jsonStringifiedDate}`);
+ })
+
+ it('should interpolate Date() when its nested in an object', () => {
+ const inputString = 'Date is {{date}}';
+ const inputObject = {
+ date: {
+ now: new Date("2025-04-17T15:33:41.117Z")
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Date is {"now":"2025-04-17T15:33:41.117Z"}');
+ })
+});
+
+describe('interpolate - moment() handling', () => {
+ it('should interpolate moment() using JSON.stringify', () => {
+ const inputString = 'Date is {{date}}';
+ const inputObject = {
+ date: moment("2025-04-17T15:33:41.117Z")
+ };
+
+ const jsonStringifiedDate = JSON.stringify(inputObject.date);
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Date is "2025-04-17T15:33:41.117Z"');
+ expect(result).toBe(`Date is ${jsonStringifiedDate}`);
+ })
+
+ it('should interpolate moment() when its nested in an object', () => {
+ const inputString = 'Date is {{date}}';
+ const inputObject = {
+ date: {
+ now: moment("2025-04-17T15:33:41.117Z")
+ }
+ };
+
+ const result = interpolate(inputString, inputObject);
+
+ expect(result).toBe('Date is {"now":"2025-04-17T15:33:41.117Z"}');
+ })
+})
\ No newline at end of file
diff --git a/packages/bruno-common/src/interpolate/index.ts b/packages/bruno-common/src/interpolate/index.ts
index 8ad86c5b1..83d803480 100644
--- a/packages/bruno-common/src/interpolate/index.ts
+++ b/packages/bruno-common/src/interpolate/index.ts
@@ -11,21 +11,89 @@
* Output: Hello, my name is Bruno and I am 4 years old
*/
-import { flattenObject } from '../utils';
+import { mockDataFunctions } from '../utils/faker-functions';
+import { get } from "lodash-es";
-const interpolate = (str: string, obj: Record): string => {
- if (!str || typeof str !== 'string' || !obj || typeof obj !== 'object') {
+const interpolate = (
+ str: string,
+ obj: Record,
+ options: { escapeJSONStrings?: boolean } = { escapeJSONStrings: false }
+): string => {
+ if (!str || typeof str !== 'string') {
return str;
}
- const patternRegex = /\{\{([^}]+)\}\}/g;
- const flattenedObj = flattenObject(obj);
- const result = str.replace(patternRegex, (match, placeholder) => {
- const replacement = flattenedObj[placeholder];
- return replacement !== undefined ? replacement : match;
+ const { escapeJSONStrings } = options;
+
+ const patternRegex = /\{\{\$(\w+)\}\}/g;
+ str = str.replace(patternRegex, (match, keyword) => {
+ let replacement = mockDataFunctions[keyword as keyof typeof mockDataFunctions]?.();
+
+ if (replacement === undefined) return match;
+ replacement = String(replacement);
+
+ if (!escapeJSONStrings) return replacement;
+
+ // All the below chars inside of a JSON String field
+ // will make it invalid JSON. So we will have to escape them with `\`.
+ // This is not exhaustive but selective to what faker-js can output.
+ if (!/[\\\n\r\t\"]/.test(replacement)) return replacement;
+ return replacement
+ .replace(/\\/g, '\\\\')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\t/g, '\\t')
+ .replace(/\"/g, '\\"');
});
- return result;
+ if (!obj || typeof obj !== 'object') {
+ return str;
+ }
+
+ return replace(str, obj);
};
-export default interpolate;
+const replace = (
+ str: string,
+ obj: Record,
+ visited = new Set(),
+ results = new Map()
+): string => {
+ let resultStr = str;
+ let matchFound = true;
+
+ while (matchFound) {
+ const patternRegex = /\{\{([^}]+)\}\}/g;
+ matchFound = false;
+ resultStr = resultStr.replace(patternRegex, (match, placeholder) => {
+ let replacement = get(obj, placeholder);
+ if (typeof replacement === 'object' && replacement !== null) {
+ replacement = JSON.stringify(replacement);
+ }
+
+ if (results.has(match)) {
+ return results.get(match);
+ }
+
+ if (patternRegex.test(replacement) && !visited.has(match)) {
+ visited.add(match);
+ const result = replace(replacement, obj, visited, results);
+ results.set(match, result);
+
+ matchFound = true;
+ return result;
+ }
+
+ visited.add(match);
+ const result = replacement !== undefined ? replacement : match;
+ results.set(match, result);
+
+ matchFound = true;
+ return result;
+ });
+ }
+
+ return resultStr;
+};
+
+export default interpolate;
\ No newline at end of file
diff --git a/packages/bruno-common/src/runner/index.ts b/packages/bruno-common/src/runner/index.ts
new file mode 100644
index 000000000..3e3c842b0
--- /dev/null
+++ b/packages/bruno-common/src/runner/index.ts
@@ -0,0 +1,4 @@
+import { generateHtmlReport } from "./reports/html/generate-report";
+import { getRunnerSummary } from "./runner-summary";
+
+export { generateHtmlReport, getRunnerSummary };
\ No newline at end of file
diff --git a/packages/bruno-common/src/runner/reports/html/generate-report.ts b/packages/bruno-common/src/runner/reports/html/generate-report.ts
new file mode 100644
index 000000000..7309c483e
--- /dev/null
+++ b/packages/bruno-common/src/runner/reports/html/generate-report.ts
@@ -0,0 +1,38 @@
+import { T_RunnerResults } from "../../types";
+import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from "../../utils";
+import htmlTemplateString from "./template";
+
+const generateHtmlReport = ({
+ runnerResults
+}: {
+ runnerResults: T_RunnerResults[]
+}): string => {
+ const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
+ return {
+ iterationIndex,
+ results: results.map((result) => {
+ const { request, response } = result || {};
+ const requestContentType = request?.headers ? getContentType(request?.headers) : '';
+ const responseContentType = response?.headers ? getContentType(response?.headers) : '';
+ return {
+ ...result,
+ request: {
+ ...result.request,
+ data: request?.data ? redactImageData(request?.data, requestContentType) : request?.data,
+ isHtml: isHtmlContentType(requestContentType)
+ },
+ response: {
+ ...result.response,
+ data: response?.data ? redactImageData(response?.data, responseContentType) : response?.data,
+ isHtml: isHtmlContentType(responseContentType)
+ }
+ }
+ }),
+ summary
+ }
+ });
+ const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));
+ return htmlString;
+};
+
+export { generateHtmlReport }
\ No newline at end of file
diff --git a/packages/bruno-common/src/runner/reports/html/template.ts b/packages/bruno-common/src/runner/reports/html/template.ts
new file mode 100644
index 000000000..d19e2077f
--- /dev/null
+++ b/packages/bruno-common/src/runner/reports/html/template.ts
@@ -0,0 +1,654 @@
+export const htmlTemplateString = (resutsJsonString: string) =>`
+
+
+
+
+
+
+
+ Bruno
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dark
+ Light
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+export default htmlTemplateString;
diff --git a/packages/bruno-common/src/runner/runner-summary.ts b/packages/bruno-common/src/runner/runner-summary.ts
new file mode 100644
index 000000000..4fb276a29
--- /dev/null
+++ b/packages/bruno-common/src/runner/runner-summary.ts
@@ -0,0 +1,98 @@
+import { T_RunnerRequestExecutionResult, T_RunSummary } from "./types";
+
+// todo: this is generic, not specific to html, can be moved out of the report/html sub-package
+export const getRunnerSummary = (results: T_RunnerRequestExecutionResult[]): T_RunSummary => {
+ let totalRequests = 0;
+ let passedRequests = 0;
+ let failedRequests = 0;
+ let errorRequests = 0;
+ let skippedRequests = 0;
+ let totalAssertions = 0;
+ let passedAssertions = 0;
+ let failedAssertions = 0;
+ let totalTests = 0;
+ let passedTests = 0;
+ let failedTests = 0;
+ let totalPreRequestTests = 0;
+ let passedPreRequestTests = 0;
+ let failedPreRequestTests = 0;
+ let totalPostResponseTests = 0;
+ let passedPostResponseTests = 0;
+ let failedPostResponseTests = 0;
+
+ for (const result of results || []) {
+ const { status, testResults, assertionResults, preRequestTestResults, postResponseTestResults } = result;
+ totalRequests += 1;
+ totalTests += Number(testResults?.length) || 0;
+ totalAssertions += Number(assertionResults?.length) || 0;
+ totalPreRequestTests += Number(preRequestTestResults?.length) || 0;
+ totalPostResponseTests += Number(postResponseTestResults?.length) || 0;
+
+ if (status === 'skipped') {
+ skippedRequests += 1;
+ continue;
+ }
+
+ let anyFailed = false;
+ for (const testResult of testResults || []) {
+ if (testResult.status === "pass") {
+ passedTests += 1;
+ } else {
+ anyFailed = true;
+ failedTests += 1;
+ }
+ }
+ for (const assertionResult of assertionResults || []) {
+ if (assertionResult.status === "pass") {
+ passedAssertions += 1;
+ } else {
+ anyFailed = true;
+ failedAssertions += 1;
+ }
+ }
+ for (const preRequestTestResult of preRequestTestResults || []) {
+ if (preRequestTestResult.status === "pass") {
+ passedPreRequestTests += 1;
+ } else {
+ anyFailed = true;
+ failedPreRequestTests += 1;
+ }
+ }
+ for (const postResponseTestResult of postResponseTestResults || []) {
+ if (postResponseTestResult.status === "pass") {
+ passedPostResponseTests += 1;
+ } else {
+ anyFailed = true;
+ failedPostResponseTests += 1;
+ }
+ }
+
+ if (!anyFailed && status !== "error") {
+ passedRequests += 1;
+ } else if (anyFailed) {
+ failedRequests += 1;
+ } else {
+ errorRequests += 1;
+ }
+ }
+
+ return {
+ totalRequests,
+ passedRequests,
+ failedRequests,
+ errorRequests,
+ skippedRequests,
+ totalAssertions,
+ passedAssertions,
+ failedAssertions,
+ totalTests,
+ passedTests,
+ failedTests,
+ totalPreRequestTests,
+ passedPreRequestTests,
+ failedPreRequestTests,
+ totalPostResponseTests,
+ passedPostResponseTests,
+ failedPostResponseTests,
+ };
+};
\ No newline at end of file
diff --git a/packages/bruno-common/src/runner/types/index.ts b/packages/bruno-common/src/runner/types/index.ts
new file mode 100644
index 000000000..3565930f2
--- /dev/null
+++ b/packages/bruno-common/src/runner/types/index.ts
@@ -0,0 +1,123 @@
+// assertion results types
+type T_AssertionPassResult = {
+ lhsExpr: string;
+ rhsExpr: string;
+ rhsOperand: string;
+ operator: string;
+ status: string;
+}
+
+type T_AssertionFailResult = {
+ lhsExpr: string;
+ rhsExpr: string;
+ rhsOperand: string;
+ operator: string;
+ status: string;
+ error: string;
+}
+
+type T_AssertionResult = T_AssertionPassResult | T_AssertionFailResult;
+
+// test results types
+type T_TestPassResult = {
+ status: string;
+ description: string;
+ uid?: string;
+};
+
+type T_TestFailResult = {
+ status: string;
+ description: string;
+ error: string;
+ uid?: string;
+};
+
+type T_TestResult = T_TestPassResult | T_TestFailResult;
+
+type T_EmptyRequest = {
+ method?: null | undefined;
+ url?: null | undefined;
+ headers?: null | undefined;
+ data?: null | undefined;
+ isHtml?: boolean | undefined;
+}
+
+// request types
+type T_Request = {
+ method: string;
+ url: string;
+ headers: Record;
+ data: string | object | null | boolean | number;
+ isHtml?: boolean;
+};
+
+type T_EmptyResponse = {
+ status?: null | undefined;
+ statusText?: null | undefined;
+ headers?: null | undefined;
+ data?: null | undefined;
+ responseTime?: number | undefined;
+ isHtml?: boolean | undefined;
+}
+
+type T_SkippedResponse = {
+ status?: string | null | undefined;
+ statusText?: string | null | undefined;
+ headers?: null | undefined;
+ data?: null | undefined;
+ responseTime?: number | undefined;
+ isHtml?: boolean | undefined;
+}
+
+// response types
+type T_Response = {
+ status: number | string;
+ statusText: string;
+ headers: Record;
+ data: string | object | null | boolean | number;
+ isHtml?: boolean;
+};
+
+// result type
+export type T_RunnerRequestExecutionResult = {
+ iterationIndex: number;
+ name: string;
+ path: string;
+ request: T_EmptyRequest | T_Request;
+ response: T_EmptyResponse | T_Response | T_SkippedResponse;
+ status: null | undefined | string;
+ error: null | undefined | string;
+ assertionResults?: T_AssertionResult[];
+ testResults?: T_TestResult[];
+ preRequestTestResults?: T_TestResult[];
+ postResponseTestResults?: T_TestResult[];
+ runDuration: number;
+}
+
+export type T_RunnerResults = {
+ iterationIndex: number;
+ iterationData?: any; // todo - csv/json row data
+ results: T_RunnerRequestExecutionResult[];
+ summary: T_RunSummary;
+}
+
+// run summary type
+export type T_RunSummary = {
+ totalRequests: number;
+ passedRequests: number;
+ failedRequests: number;
+ errorRequests: number;
+ skippedRequests: number;
+ totalAssertions: number;
+ passedAssertions: number;
+ failedAssertions: number;
+ totalTests: number;
+ passedTests: number;
+ failedTests: number;
+ totalPreRequestTests: number;
+ passedPreRequestTests: number;
+ failedPreRequestTests: number;
+ totalPostResponseTests: number;
+ passedPostResponseTests: number;
+ failedPostResponseTests: number;
+}
\ No newline at end of file
diff --git a/packages/bruno-common/src/runner/utils/index.ts b/packages/bruno-common/src/runner/utils/index.ts
new file mode 100644
index 000000000..76f1f774c
--- /dev/null
+++ b/packages/bruno-common/src/runner/utils/index.ts
@@ -0,0 +1,31 @@
+export const encodeBase64 = (str: string) => {
+ const bytes = new TextEncoder().encode(str);
+ const binary = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), '');
+ return btoa(binary);
+}
+
+export const decodeBase64 = (base64: string) => {
+ const binary = atob(base64);
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+}
+
+export const getContentType = (headers: Record): string => {
+ if (!headers || typeof headers !== 'object') {
+ return '';
+ }
+ const contentType = Object.entries(headers)
+ .find(([key]) => key.toLowerCase() === 'content-type')?.[1];
+ return typeof contentType === 'string' ? contentType : '';
+};
+
+export const isHtmlContentType = (contentType: string) => {
+ return contentType?.includes("html");
+};
+
+export const redactImageData = (data: string | object | number | boolean, contentType: string) => {
+ if (contentType?.includes("image")) {
+ return "Response content redacted (image data)";
+ }
+ return data;
+}
\ No newline at end of file
diff --git a/packages/bruno-common/src/utils/faker-functions.spec.ts b/packages/bruno-common/src/utils/faker-functions.spec.ts
new file mode 100644
index 000000000..61388ee9d
--- /dev/null
+++ b/packages/bruno-common/src/utils/faker-functions.spec.ts
@@ -0,0 +1,156 @@
+import { mockDataFunctions } from "./faker-functions";
+
+describe("mockDataFunctions Regex Validation", () => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ test("timestamp and isoTimestamp should return mocked time values", () => {
+ const expectedTimestamp = '1704067200';
+ const expectedIsoTimestamp = '2024-01-01T00:00:00.000Z';
+
+ expect(mockDataFunctions.timestamp()).toBe(expectedTimestamp);
+ expect(mockDataFunctions.isoTimestamp()).toBe(expectedIsoTimestamp);
+ });
+
+ test("all values should match their expected patterns", () => {
+ const patterns: Record = {
+ guid: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
+ randomUUID: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
+ randomAlphaNumeric: /^[\w]$/,
+ randomBoolean: /^(true|false)$/,
+ randomInt: /^\d+$/,
+ randomColor: /^[\w\s]+$/,
+ randomHexColor: /^#[\da-f]{6}$/,
+ randomAbbreviation: /^\w{2,6}$/,
+ randomIP: /^([\da-f]{1,4}:){7}[\da-f]{1,4}$|^(\d{1,3}\.){3}\d{1,3}$/,
+ randomIPV4: /^(\d{1,3}\.){3}\d{1,3}$/,
+ randomIPV6: /^([\da-f]{1,4}:){7}[\da-f]{1,4}$/,
+ randomMACAddress: /^([\da-f]{2}:){5}[\da-f]{2}$/,
+ randomPassword: /^[\w\d]{8,}$/,
+ randomLocale: /^[A-Z]{2}$/,
+ randomUserAgent: /^[\w\/\.\s\(\)\+\-;:_,]+$/,
+ randomProtocol: /^(http|https|ftp)s?$/,
+ randomSemver: /^\d+\.\d+\.\d+$/,
+ randomFirstName: /^[\s\S]+$/,
+ randomLastName: /^[\s\S]+$/,
+ randomFullName: /^[\s\S]+$/,
+ randomNamePrefix: /^[\s\S]+$/,
+ randomNameSuffix: /^[\s\S]+$/,
+ randomJobArea: /^[\s\S]+$/,
+ randomJobDescriptor: /^[\s\S]+$/,
+ randomJobTitle: /^[\s\S]+$/,
+ randomJobType: /^[\s\S]+$/,
+ randomPhoneNumber: /^[\s\S]+$/,
+ randomPhoneNumberExt: /^[\s\S]+$/,
+ randomCity: /^[\s\S]+$/,
+ randomStreetName: /^[\s\S]+$/,
+ randomStreetAddress: /^[\s\S]+$/,
+ randomCountry: /^[\s\S]+$/,
+ randomCountryCode: /^[\s\S]+$/,
+ randomLatitude: /^[\s\S]+$/,
+ randomLongitude: /^[\s\S]+$/,
+ randomAvatarImage: /^[\s\S]+$/,
+ randomImageUrl: /^[\s\S]+$/,
+ randomAbstractImage: /^[\s\S]+$/,
+ randomAnimalsImage: /^[\s\S]+$/,
+ randomBusinessImage: /^[\s\S]+$/,
+ randomCatsImage: /^[\s\S]+$/,
+ randomCityImage: /^[\s\S]+$/,
+ randomFoodImage: /^[\s\S]+$/,
+ randomNightlifeImage: /^[\s\S]+$/,
+ randomFashionImage: /^[\s\S]+$/,
+ randomPeopleImage: /^[\s\S]+$/,
+ randomNatureImage: /^[\s\S]+$/,
+ randomSportsImage: /^[\s\S]+$/,
+ randomTransportImage: /^[\s\S]+$/,
+ randomImageDataUri: /^[\s\S]+$/,
+ randomBankAccount: /^[\s\S]+$/,
+ randomBankAccountName: /^[\s\S]+$/,
+ randomCreditCardMask: /^[\s\S]+$/,
+ randomBankAccountBic: /^[\s\S]+$/,
+ randomBankAccountIban: /^[\s\S]+$/,
+ randomTransactionType: /^[\s\S]+$/,
+ randomCurrencyCode: /^[\s\S]+$/,
+ randomCurrencyName: /^[\s\S]+$/,
+ randomCurrencySymbol: /^[\s\S]+$/,
+ randomBitcoin: /^[\s\S]+$/,
+ randomCompanyName: /^[\s\S]+$/,
+ randomCompanySuffix: /^[\s\S]+$/,
+ randomBs: /^[\s\S]+$/,
+ randomBsAdjective: /^[\s\S]+$/,
+ randomBsBuzz: /^[\s\S]+$/,
+ randomBsNoun: /^[\s\S]+$/,
+ randomCatchPhrase: /^[\s\S]+$/,
+ randomCatchPhraseAdjective: /^[\s\S]+$/,
+ randomCatchPhraseDescriptor: /^[\s\S]+$/,
+ randomCatchPhraseNoun: /^[\s\S]+$/,
+ randomDatabaseColumn: /^[\s\S]+$/,
+ randomDatabaseType: /^[\s\S]+$/,
+ randomDatabaseCollation: /^[\s\S]+$/,
+ randomDatabaseEngine: /^[\s\S]+$/,
+ randomDateFuture: /^[\s\S]+$/,
+ randomDatePast: /^[\s\S]+$/,
+ randomDateRecent: /^[\s\S]+$/,
+ randomWeekday: /^[\s\S]+$/,
+ randomMonth: /^[\s\S]+$/,
+ randomDomainName: /^[\s\S]+$/,
+ randomDomainSuffix: /^[\s\S]+$/,
+ randomDomainWord: /^[\s\S]+$/,
+ randomEmail: /^[\w_.\-]+@[\w]+\.[a-z]+$/,
+ randomExampleEmail: /^[\w\.-]+@example\.[a-z]+$/,
+ randomUserName: /^[\w.\-]+$/,
+ randomUrl: /^https:\/\/[\w\-]+\.[a-z]+\/?$/,
+ randomFileName: /^[\w\_]+\.[\w\d]+$/,
+ randomFileType: /^[\w]+$/,
+ randomFileExt: /^[\w\d]+$/,
+ randomCommonFileName: /^[\w\_]+\.[\w\d]+$/,
+ randomCommonFileType: /^[\w]+$/,
+ randomCommonFileExt: /^[\w\d]+$/,
+ randomFilePath: /^[\s\S]+$/,
+ randomDirectoryPath: /^\/[-\w\+\/]+$/,
+ randomMimeType: /^[\w]+\/[\w\d\-\+\.]+$/,
+ randomPrice: /^\d+\.\d{2}$/,
+ randomProduct: /^[\s\S]+$/,
+ randomProductAdjective: /^[\s\S]+$/,
+ randomProductMaterial: /^[\s\S]+$/,
+ randomProductName: /^[\s\S]+$/,
+ randomDepartment: /^[\s\S]+$/,
+ randomNoun: /^[\s\S]+$/,
+ randomVerb: /^[\s\S]+$/,
+ randomIngverb: /^[\s\S]+$/,
+ randomAdjective: /^[\s\S]+$/,
+ randomWord: /^[\s\S]+$/,
+ randomWords: /^[\s\S]+$/,
+ randomPhrase: /^[\s\S]+$/,
+ randomLoremWord: /^[\s\S]+$/,
+ randomLoremWords: /^[\s\S]+$/,
+ randomLoremSentence: /^[\s\S]+$/,
+ randomLoremSentences: /^[\s\S]+$/,
+ randomLoremParagraph: /^[\s\S]+$/,
+ randomLoremParagraphs: /^[\s\S]+$/,
+ randomLoremText: /^[\s\S]+$/,
+ randomLoremSlug: /^[\s\S]+$/,
+ randomLoremLines: /^[\s\S]+$/,
+ };
+
+ const errors: string[] = [];
+
+ Object.entries(mockDataFunctions).forEach(([key, func]) => {
+ const pattern = patterns[key];
+ const value = String(func());
+ if (!value.match(pattern)) {
+ errors.push(`Pattern mismatch for ${key}: expected ${pattern}, received ${value}`);
+ }
+ });
+
+ if (errors.length > 0) {
+ throw new Error(errors.join("\n"));
+ }
+ });
+});
diff --git a/packages/bruno-common/src/utils/faker-functions.ts b/packages/bruno-common/src/utils/faker-functions.ts
new file mode 100644
index 000000000..1c7f14d14
--- /dev/null
+++ b/packages/bruno-common/src/utils/faker-functions.ts
@@ -0,0 +1,123 @@
+import { faker } from '@faker-js/faker';
+
+export const mockDataFunctions = {
+ guid: () => faker.string.uuid(),
+ timestamp: () => Math.floor(Date.now() / 1000).toString(),
+ isoTimestamp: () => new Date().toISOString(),
+ randomUUID: () => faker.string.uuid(),
+ randomAlphaNumeric: () => faker.string.alphanumeric(),
+ randomBoolean: () => faker.datatype.boolean(),
+ randomInt: () => faker.number.int(),
+ randomColor: () => faker.color.human(),
+ randomHexColor: () => faker.color.rgb(),
+ randomAbbreviation: () => faker.hacker.abbreviation(),
+ randomIP: () => faker.internet.ip(),
+ randomIPV4: () => faker.internet.ipv4(),
+ randomIPV6: () => faker.internet.ipv6(),
+ randomMACAddress: () => faker.internet.mac(),
+ randomPassword: () => faker.internet.password(),
+ randomLocale: () => faker.location.countryCode(),
+ randomUserAgent: () => faker.internet.userAgent(),
+ randomProtocol: () => faker.internet.protocol(),
+ randomSemver: () => faker.system.semver(),
+ randomFirstName: () => faker.person.firstName(),
+ randomLastName: () => faker.person.lastName(),
+ randomFullName: () => faker.person.fullName(),
+ randomNamePrefix: () => faker.person.prefix(),
+ randomNameSuffix: () => faker.person.suffix(),
+ randomJobArea: () => faker.person.jobArea(),
+ randomJobDescriptor: () => faker.person.jobDescriptor(),
+ randomJobTitle: () => faker.person.jobTitle(),
+ randomJobType: () => faker.person.jobType(),
+ randomPhoneNumber: () => faker.phone.number(),
+ randomPhoneNumberExt: () => faker.phone.number(),
+ randomCity: () => faker.location.city(),
+ randomStreetName: () => faker.location.street(),
+ randomStreetAddress: () => faker.location.streetAddress(),
+ randomCountry: () => faker.location.country(),
+ randomCountryCode: () => faker.location.countryCode(),
+ randomLatitude: () => faker.location.latitude(),
+ randomLongitude: () => faker.location.longitude(),
+ randomAvatarImage: () => faker.image.avatar(),
+ randomImageUrl: () => faker.image.url(),
+ randomAbstractImage: () => faker.image.urlLoremFlickr({ category: 'abstract' }),
+ randomAnimalsImage: () => faker.image.urlLoremFlickr({ category: 'animals' }),
+ randomBusinessImage: () => faker.image.urlLoremFlickr({ category: 'business' }),
+ randomCatsImage: () => faker.image.urlLoremFlickr({ category: 'cats' }),
+ randomCityImage: () => faker.image.urlLoremFlickr({ category: 'city' }),
+ randomFoodImage: () => faker.image.urlLoremFlickr({ category: 'food' }),
+ randomNightlifeImage: () => faker.image.urlLoremFlickr({ category: 'nightlife' }),
+ randomFashionImage: () => faker.image.urlLoremFlickr({ category: 'fashion' }),
+ randomPeopleImage: () => faker.image.urlLoremFlickr({ category: 'people' }),
+ randomNatureImage: () => faker.image.urlLoremFlickr({ category: 'nature' }),
+ randomSportsImage: () => faker.image.urlLoremFlickr({ category: 'sports' }),
+ randomTransportImage: () => faker.image.urlLoremFlickr({ category: 'transport' }),
+ randomImageDataUri: () => faker.image.dataUri(),
+ randomBankAccount: () => faker.finance.accountNumber(),
+ randomBankAccountName: () => faker.finance.accountName(),
+ randomCreditCardMask: () => faker.finance.iban().replace(/(?<=.{4})\w(?=.{2})/g, '*'),
+ randomBankAccountBic: () => faker.finance.bic(),
+ randomBankAccountIban: () => faker.finance.iban(),
+ randomTransactionType: () => faker.finance.transactionType(),
+ randomCurrencyCode: () => faker.finance.currencyCode(),
+ randomCurrencyName: () => faker.finance.currencyName(),
+ randomCurrencySymbol: () => faker.finance.currencySymbol(),
+ randomBitcoin: () => faker.finance.bitcoinAddress(),
+ randomCompanyName: () => faker.company.name(),
+ randomCompanySuffix: () => faker.company.name(),
+ randomBs: () => faker.company.buzzPhrase(),
+ randomBsAdjective: () => faker.company.buzzAdjective(),
+ randomBsBuzz: () => faker.company.buzzVerb(),
+ randomBsNoun: () => faker.company.buzzNoun(),
+ randomCatchPhrase: () => faker.company.catchPhrase(),
+ randomCatchPhraseAdjective: () => faker.company.catchPhraseAdjective(),
+ randomCatchPhraseDescriptor: () => faker.company.catchPhraseDescriptor(),
+ randomCatchPhraseNoun: () => faker.company.catchPhraseNoun(),
+ randomDatabaseColumn: () => faker.database.column(),
+ randomDatabaseType: () => faker.database.type(),
+ randomDatabaseCollation: () => faker.database.collation(),
+ randomDatabaseEngine: () => faker.database.engine(),
+ randomDateFuture: () => faker.date.future().toISOString(),
+ randomDatePast: () => faker.date.past().toISOString(),
+ randomDateRecent: () => faker.date.recent().toISOString(),
+ randomWeekday: () => faker.date.weekday(),
+ randomMonth: () => faker.date.month(),
+ randomDomainName: () => faker.internet.domainName(),
+ randomDomainSuffix: () => faker.internet.domainSuffix(),
+ randomDomainWord: () => faker.internet.domainWord(),
+ randomEmail: () => faker.internet.email(),
+ randomExampleEmail: () => faker.internet.exampleEmail(),
+ randomUserName: () => faker.internet.username(),
+ randomUrl: () => faker.internet.url(),
+ randomFileName: () => faker.system.fileName(),
+ randomFileType: () => faker.system.fileType(),
+ randomFileExt: () => faker.system.fileExt(),
+ randomCommonFileName: () => faker.system.commonFileName(),
+ randomCommonFileType: () => faker.system.commonFileType(),
+ randomCommonFileExt: () => faker.system.commonFileExt(),
+ randomFilePath: () => faker.system.filePath(),
+ randomDirectoryPath: () => faker.system.directoryPath(),
+ randomMimeType: () => faker.system.mimeType(),
+ randomPrice: () => faker.commerce.price(),
+ randomProduct: () => faker.commerce.product(),
+ randomProductAdjective: () => faker.commerce.productAdjective(),
+ randomProductMaterial: () => faker.commerce.productMaterial(),
+ randomProductName: () => faker.commerce.productName(),
+ randomDepartment: () => faker.commerce.department(),
+ randomNoun: () => faker.hacker.noun(),
+ randomVerb: () => faker.hacker.verb(),
+ randomIngverb: () => faker.hacker.ingverb(),
+ randomAdjective: () => faker.hacker.adjective(),
+ randomWord: () => faker.hacker.noun(),
+ randomWords: () => faker.lorem.words(),
+ randomPhrase: () => faker.hacker.phrase(),
+ randomLoremWord: () => faker.lorem.word(),
+ randomLoremWords: () => faker.lorem.words(),
+ randomLoremSentence: () => faker.lorem.sentence(),
+ randomLoremSentences: () => faker.lorem.sentences(),
+ randomLoremParagraph: () => faker.lorem.paragraph(),
+ randomLoremParagraphs: () => faker.lorem.paragraphs(),
+ randomLoremText: () => faker.lorem.text(),
+ randomLoremSlug: () => faker.lorem.slug(),
+ randomLoremLines: () => faker.lorem.lines()
+};
diff --git a/packages/bruno-common/src/utils/index.spec.ts b/packages/bruno-common/src/utils/index.spec.ts
deleted file mode 100644
index 09689ac65..000000000
--- a/packages/bruno-common/src/utils/index.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { flattenObject } from './index';
-
-describe('flattenObject', () => {
- it('should flatten a simple object', () => {
- const input = { a: 1, b: { c: 2, d: { e: 3 } } };
- const output = flattenObject(input);
- expect(output).toEqual({ a: 1, 'b.c': 2, 'b.d.e': 3 });
- });
-
- it('should flatten an object with arrays', () => {
- const input = { a: 1, b: { c: [2, 3, 4], d: { e: 5 } } };
- const output = flattenObject(input);
- expect(output).toEqual({ a: 1, 'b.c[0]': 2, 'b.c[1]': 3, 'b.c[2]': 4, 'b.d.e': 5 });
- });
-
- it('should flatten an object with arrays having objects', () => {
- const input = { a: 1, b: { c: [{ d: 2 }, { e: 3 }], f: { g: 4 } } };
- const output = flattenObject(input);
- expect(output).toEqual({ a: 1, 'b.c[0].d': 2, 'b.c[1].e': 3, 'b.f.g': 4 });
- });
-
- it('should handle null values', () => {
- const input = { a: 1, b: { c: null, d: { e: 3 } } };
- const output = flattenObject(input);
- expect(output).toEqual({ a: 1, 'b.c': null, 'b.d.e': 3 });
- });
-
- it('should handle an empty object', () => {
- const input = {};
- const output = flattenObject(input);
- expect(output).toEqual({});
- });
-
- it('should handle an object with nested empty objects', () => {
- const input = { a: { b: {}, c: { d: {} } } };
- const output = flattenObject(input);
- expect(output).toEqual({});
- });
-
- it('should handle an object with duplicate keys - dot notation used to define the last duplicate key', () => {
- const input = { a: { b: 2 }, 'a.b': 1 };
- const output = flattenObject(input);
- expect(output).toEqual({ 'a.b': 1 });
- });
-
- it('should handle an object with duplicate keys - inner object used to define the last duplicate key', () => {
- const input = { 'a.b': 1, a: { b: 2 } };
- const output = flattenObject(input);
- expect(output).toEqual({ 'a.b': 2 });
- });
-});
diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts
deleted file mode 100644
index bba8f1310..000000000
--- a/packages/bruno-common/src/utils/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export const flattenObject = (obj: Record, parentKey: string = ''): Record => {
- return Object.entries(obj).reduce((acc: Record, [key, value]: [string, any]) => {
- const newKey = parentKey ? (Array.isArray(obj) ? `${parentKey}[${key}]` : `${parentKey}.${key}`) : key;
- if (typeof value === 'object' && value !== null) {
- Object.assign(acc, flattenObject(value, newKey));
- } else {
- acc[newKey] = value;
- }
- return acc;
- }, {});
-};
diff --git a/packages/bruno-common/tsconfig.json b/packages/bruno-common/tsconfig.json
index 57a8bcc74..9978d57dc 100644
--- a/packages/bruno-common/tsconfig.json
+++ b/packages/bruno-common/tsconfig.json
@@ -6,14 +6,14 @@
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
- "declaration": true,
- "declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
- "emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
- "forceConsistentCasingInFileNames": true
+ "forceConsistentCasingInFileNames": true,
+ "allowJs": true,
+ "checkJs": false
},
+ "include": ["src/**/*.ts", "src/**/*.js"],
"exclude": ["dist", "node_modules", "tests"]
}
diff --git a/packages/bruno-converters/.gitignore b/packages/bruno-converters/.gitignore
new file mode 100644
index 000000000..f6eabff32
--- /dev/null
+++ b/packages/bruno-converters/.gitignore
@@ -0,0 +1,22 @@
+# dependencies
+node_modules
+yarn.lock
+pnpm-lock.yaml
+package-lock.json
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# production
+dist
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/packages/bruno-converters/babel.config.js b/packages/bruno-converters/babel.config.js
new file mode 100644
index 000000000..c74fb53e2
--- /dev/null
+++ b/packages/bruno-converters/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
+};
diff --git a/packages/bruno-converters/jest.config.js b/packages/bruno-converters/jest.config.js
new file mode 100644
index 000000000..fa59852bc
--- /dev/null
+++ b/packages/bruno-converters/jest.config.js
@@ -0,0 +1,13 @@
+module.exports = {
+ transform: {
+ '^.+\\.js$': 'babel-jest',
+ },
+ setupFiles: ['/jest.setup.js'],
+ transformIgnorePatterns: [
+ 'node_modules/(?!(nanoid)/)'
+ ],
+ testEnvironment: 'node',
+ moduleNameMapper: {
+ '^nanoid(/(.*)|$)': 'nanoid$1'
+ }
+};
diff --git a/packages/bruno-converters/jest.setup.js b/packages/bruno-converters/jest.setup.js
new file mode 100644
index 000000000..a2246e495
--- /dev/null
+++ b/packages/bruno-converters/jest.setup.js
@@ -0,0 +1,11 @@
+// Mock the uuid function
+jest.mock('./src/common', () => {
+ // Import the original module to keep other functions intact
+ const originalModule = jest.requireActual('./src/common');
+
+ return {
+ __esModule: true, // Use this property to indicate it's an ES module
+ ...originalModule,
+ uuid: jest.fn(() => 'mockeduuidvalue123456'), // Mock uuid to return a fixed value
+ };
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/license.md b/packages/bruno-converters/license.md
new file mode 100644
index 000000000..3628f730f
--- /dev/null
+++ b/packages/bruno-converters/license.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Anoop M D, Anusree P S and Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/bruno-converters/package.json b/packages/bruno-converters/package.json
new file mode 100644
index 000000000..b942dea98
--- /dev/null
+++ b/packages/bruno-converters/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@usebruno/converters",
+ "version": "0.1.0",
+ "license": "MIT",
+ "main": "dist/cjs/index.js",
+ "module": "dist/esm/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist",
+ "src",
+ "package.json"
+ ],
+ "scripts": {
+ "clean": "rimraf dist",
+ "test": "node --experimental-vm-modules $(npx which jest) --colors --collectCoverage",
+ "prebuild": "npm run clean",
+ "build": "rollup -c",
+ "watch": "rollup -c -w",
+ "prepack": "npm run test && npm run build"
+ },
+ "dependencies": {
+ "@usebruno/schema": "^0.7.0",
+ "js-yaml": "^4.1.0",
+ "jscodeshift": "^17.3.0",
+ "lodash": "^4.17.21",
+ "nanoid": "3.3.8"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.25.2",
+ "@babel/preset-env": "^7.25.4",
+ "@rollup/plugin-alias": "^5.1.0",
+ "@rollup/plugin-commonjs": "^23.0.2",
+ "@rollup/plugin-node-resolve": "^15.0.1",
+ "@rollup/plugin-typescript": "^9.0.2",
+ "@web/rollup-plugin-copy": "^0.5.1",
+ "babel-jest": "^29.7.0",
+ "rimraf": "^5.0.7",
+ "rollup": "3.2.5",
+ "rollup-plugin-dts": "^5.0.0",
+ "rollup-plugin-peer-deps-external": "^2.2.4",
+ "rollup-plugin-terser": "^7.0.2",
+ "typescript": "^4.8.4"
+ },
+ "overrides": {
+ "rollup": "3.2.5"
+ }
+}
diff --git a/packages/bruno-converters/readme.md b/packages/bruno-converters/readme.md
new file mode 100644
index 000000000..af69f5268
--- /dev/null
+++ b/packages/bruno-converters/readme.md
@@ -0,0 +1,78 @@
+# bruno-converters
+
+The converters package is responsible for converting collections from one format to a Bruno collection.
+It can be used as a standalone package or as a part of the Bruno framework.
+
+## Installation
+
+```bash
+npm install @usebruno/converters
+```
+
+## Usage
+
+### Convert Postman collection to Bruno collection
+
+```javascript
+const { postmanToBruno } = require('@usebruno/converters');
+
+// Convert Postman collection to Bruno collection
+const brunoCollection = postmanToBruno(postmanCollection);
+```
+
+### Convert Postman Environment to Bruno Environment
+
+```javascript
+const { postmanToBrunoEnvironment } = require('@usebruno/converters');
+
+const brunoEnvironment = postmanToBrunoEnvironment(postmanEnvironment);
+```
+
+### Convert Insomnia collection to Bruno collection
+
+```javascript
+import { insomniaToBruno } from '@usebruno/converters';
+
+const brunoCollection = insomniaToBruno(insomniaCollection);
+```
+
+### Convert OpenAPI specification to Bruno collection
+
+```javascript
+import { openApiToBruno } from '@usebruno/converters';
+
+const brunoCollection = openApiToBruno(openApiSpecification);
+```
+
+## Example
+
+```bash copy
+
+const { postmanToBruno } = require('@usebruno/converters');
+const fs = require('fs/promises');
+const path = require('path');
+
+async function convertPostmanToBruno(inputFile, outputFile) {
+ try {
+ // Read Postman collection file
+ const inputData = await fs.readFile(inputFile, 'utf8');
+
+ // Convert to Bruno collection
+ const brunoCollection = postmanToBruno(JSON.parse(inputData));
+
+ // Save Bruno collection
+ await fs.writeFile(outputFile, JSON.stringify(brunoCollection, null, 2));
+
+ console.log('Conversion successful!');
+ } catch (error) {
+ console.error('Error during conversion:', error);
+ }
+}
+
+// Usage
+const inputFilePath = path.resolve(__dirname, 'demo_collection.postman_collection.json');
+const outputFilePath = path.resolve(__dirname, 'bruno-collection.json');
+
+convertPostmanToBruno(inputFilePath, outputFilePath);
+
+```
\ No newline at end of file
diff --git a/packages/bruno-converters/rollup.config.js b/packages/bruno-converters/rollup.config.js
new file mode 100644
index 000000000..ec9a7a4c9
--- /dev/null
+++ b/packages/bruno-converters/rollup.config.js
@@ -0,0 +1,43 @@
+const { nodeResolve } = require('@rollup/plugin-node-resolve');
+const commonjs = require('@rollup/plugin-commonjs');
+const { terser } = require('rollup-plugin-terser');
+const peerDepsExternal = require('rollup-plugin-peer-deps-external');
+const { copy } = require('@web/rollup-plugin-copy');
+
+const packageJson = require('./package.json');
+const alias = require('@rollup/plugin-alias');
+const path = require('path');
+
+module.exports = [
+ {
+ input: 'src/index.js',
+ output: [
+ {
+ dir: path.dirname(packageJson.main),
+ format: 'cjs',
+ sourcemap: true
+ },
+ {
+ dir: path.dirname(packageJson.module),
+ format: 'esm',
+ sourcemap: true
+ }
+ ],
+ plugins: [
+ peerDepsExternal(),
+ nodeResolve({
+ preferBuiltins: true,
+ extensions: ['.js', '.css'] // Resolve .js files
+ }),
+ commonjs(),
+ terser(),
+ alias({
+ entries: [{ find: 'src', replacement: path.resolve(__dirname, 'src') }]
+ }),
+ copy({
+ patterns: 'src/workers/scripts/**/*',
+ rootDir: '.'
+ })
+ ]
+ }
+];
diff --git a/packages/bruno-converters/src/common/index.js b/packages/bruno-converters/src/common/index.js
new file mode 100644
index 000000000..bc8c32cb4
--- /dev/null
+++ b/packages/bruno-converters/src/common/index.js
@@ -0,0 +1,212 @@
+import each from 'lodash/each';
+import get from 'lodash/get';
+import { customAlphabet } from 'nanoid';
+import cloneDeep from 'lodash/cloneDeep';
+import { collectionSchema } from '@usebruno/schema';
+
+export const safeParseJSON = (str) => {
+ if (!str || !str.length || typeof str !== 'string') {
+ return str;
+ }
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return str;
+ }
+};
+
+export const safeStringifyJSON = (obj, indent = false) => {
+ if (obj === undefined) {
+ return obj;
+ }
+ try {
+ if (indent) {
+ return JSON.stringify(obj, null, 2);
+ }
+ return JSON.stringify(obj);
+ } catch (e) {
+ return obj;
+ }
+};
+
+export const isItemARequest = (item) => {
+ return item.hasOwnProperty('request') && ['http-request', 'graphql-request'].includes(item.type) && !item.items;
+};
+
+// a customized version of nanoid without using _ and -
+export const uuid = () => {
+ // https://github.com/ai/nanoid/blob/main/url-alphabet/index.js
+ const urlAlphabet = 'useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict';
+ const customNanoId = customAlphabet(urlAlphabet, 21);
+
+ return customNanoId();
+};
+
+export const validateSchema = (collection = {}) => {
+ try {
+ collectionSchema.validateSync(collection);
+ return collection;
+ } catch (err) {
+ console.log("Error validating schema", err);
+ throw new Error('The Collection has an invalid schema');
+ }
+};
+
+export const updateUidsInCollection = (_collection) => {
+ const collection = cloneDeep(_collection);
+
+ collection.uid = uuid();
+
+ const updateItemUids = (items = []) => {
+ each(items, (item) => {
+ item.uid = uuid();
+
+ each(get(item, 'request.headers'), (header) => (header.uid = uuid()));
+ each(get(item, 'request.params'), (param) => (param.uid = uuid()));
+ each(get(item, 'request.vars.req'), (v) => (v.uid = uuid()));
+ each(get(item, 'request.vars.res'), (v) => (v.uid = uuid()));
+ each(get(item, 'request.assertions'), (a) => (a.uid = uuid()));
+ each(get(item, 'request.body.multipartForm'), (param) => (param.uid = uuid()));
+ each(get(item, 'request.body.formUrlEncoded'), (param) => (param.uid = uuid()));
+ each(get(item, 'request.body.file'), (param) => (param.uid = uuid()));
+
+ if (item.items && item.items.length) {
+ updateItemUids(item.items);
+ }
+ });
+ };
+ updateItemUids(collection.items);
+
+ const updateEnvUids = (envs = []) => {
+ each(envs, (env) => {
+ env.uid = uuid();
+ each(env.variables, (variable) => (variable.uid = uuid()));
+ });
+ };
+ updateEnvUids(collection.environments);
+
+ return collection;
+};
+
+// todo
+// need to eventually get rid of supporting old collection app models
+// 1. start with making request type a constant fetched from a single place
+// 2. move references of param and replace it with query inside the app
+export const transformItemsInCollection = (collection) => {
+ const transformItems = (items = []) => {
+ each(items, (item) => {
+
+ if (['http', 'graphql'].includes(item.type)) {
+ item.type = `${item.type}-request`;
+
+ if (item.request.query) {
+ item.request.params = item.request.query.map((queryItem) => ({
+ ...queryItem,
+ type: 'query',
+ uid: queryItem.uid || uuid()
+ }));
+ }
+
+ delete item.request.query;
+
+ // from 5 feb 2024, multipartFormData needs to have a type
+ // this was introduced when we added support for file uploads
+ // below logic is to make older collection exports backward compatible
+ let multipartFormData = get(item, 'request.body.multipartForm');
+ if (multipartFormData) {
+ each(multipartFormData, (form) => {
+ if (!form.type) {
+ form.type = 'text';
+ }
+ });
+ }
+ }
+
+ if (item.items && item.items.length) {
+ transformItems(item.items);
+ }
+ });
+ };
+
+ transformItems(collection.items);
+
+ return collection;
+};
+
+export const hydrateSeqInCollection = (collection) => {
+ const hydrateSeq = (items = []) => {
+ let index = 1;
+ each(items, (item) => {
+ if (isItemARequest(item) && !item.seq) {
+ item.seq = index;
+ index++;
+ }
+ if (item.items && item.items.length) {
+ hydrateSeq(item.items);
+ }
+ });
+ };
+ hydrateSeq(collection.items);
+
+ return collection;
+};
+
+export const deleteUidsInItems = (items) => {
+ each(items, (item) => {
+ delete item.uid;
+
+ if (['http-request', 'graphql-request'].includes(item.type)) {
+ each(get(item, 'request.headers'), (header) => delete header.uid);
+ each(get(item, 'request.params'), (param) => delete param.uid);
+ each(get(item, 'request.vars.req'), (v) => delete v.uid);
+ each(get(item, 'request.vars.res'), (v) => delete v.uid);
+ each(get(item, 'request.vars.assertions'), (a) => delete a.uid);
+ each(get(item, 'request.body.multipartForm'), (param) => delete param.uid);
+ each(get(item, 'request.body.formUrlEncoded'), (param) => delete param.uid);
+ each(get(item, 'request.body.file'), (param) => delete param.uid);
+ }
+
+ if (item.items && item.items.length) {
+ deleteUidsInItems(item.items);
+ }
+ });
+};
+
+/**
+ * Some of the models in the app are not consistent with the Collection Json format
+ * This function is used to transform the models to the Collection Json format
+ */
+export const transformItem = (items = []) => {
+ each(items, (item) => {
+ if (['http-request', 'graphql-request'].includes(item.type)) {
+ if (item.type === 'graphql-request') {
+ item.type = 'graphql';
+ }
+
+ if (item.type === 'http-request') {
+ item.type = 'http';
+ }
+ }
+
+ if (item.items && item.items.length) {
+ transformItem(item.items);
+ }
+ });
+};
+
+export const deleteUidsInEnvs = (envs) => {
+ each(envs, (env) => {
+ delete env.uid;
+ each(env.variables, (variable) => delete variable.uid);
+ });
+};
+
+export const deleteSecretsInEnvs = (envs) => {
+ each(envs, (env) => {
+ each(env.variables, (variable) => {
+ if (variable.secret) {
+ variable.value = '';
+ }
+ });
+ });
+};
\ No newline at end of file
diff --git a/packages/bruno-converters/src/constants/index.js b/packages/bruno-converters/src/constants/index.js
new file mode 100644
index 000000000..723a09fea
--- /dev/null
+++ b/packages/bruno-converters/src/constants/index.js
@@ -0,0 +1,3 @@
+import { invalidVariableCharacterRegex } from './regex';
+
+export { invalidVariableCharacterRegex };
diff --git a/packages/bruno-converters/src/constants/regex.js b/packages/bruno-converters/src/constants/regex.js
new file mode 100644
index 000000000..989dbc1c3
--- /dev/null
+++ b/packages/bruno-converters/src/constants/regex.js
@@ -0,0 +1 @@
+export const invalidVariableCharacterRegex = /[^\w-.]/g;
\ No newline at end of file
diff --git a/packages/bruno-converters/src/index.js b/packages/bruno-converters/src/index.js
new file mode 100644
index 000000000..d5b3d3a3b
--- /dev/null
+++ b/packages/bruno-converters/src/index.js
@@ -0,0 +1,6 @@
+export { default as postmanToBruno } from './postman/postman-to-bruno.js';
+export { default as postmanToBrunoEnvironment } from './postman/postman-env-to-bruno-env.js';
+export { default as brunoToPostman } from './postman/bruno-to-postman.js';
+export { default as openApiToBruno } from './openapi/openapi-to-bruno.js';
+export { default as insomniaToBruno } from './insomnia/insomnia-to-bruno.js';
+export { default as postmanTranslation } from './postman/postman-translations.js';
\ No newline at end of file
diff --git a/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js
new file mode 100644
index 000000000..d4d829e7b
--- /dev/null
+++ b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js
@@ -0,0 +1,312 @@
+import each from 'lodash/each';
+import get from 'lodash/get';
+import jsyaml from 'js-yaml';
+import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
+
+const parseGraphQL = (text) => {
+ try {
+ const graphql = JSON.parse(text);
+
+ return {
+ query: graphql.query,
+ variables: JSON.stringify(graphql.variables, null, 2)
+ };
+ } catch (e) {
+ return {
+ query: '',
+ variables: ''
+ };
+ }
+};
+
+const addSuffixToDuplicateName = (item, index, allItems) => {
+ // Check if the request name already exist and if so add a number suffix
+ const nameSuffix = allItems.reduce((nameSuffix, otherItem, otherIndex) => {
+ if (otherItem.name === item.name && otherIndex < index) {
+ nameSuffix++;
+ }
+ return nameSuffix;
+ }, 0);
+ return nameSuffix !== 0 ? `${item.name}_${nameSuffix}` : item.name;
+};
+
+const regexVariable = new RegExp('{{.*?}}', 'g');
+
+const normalizeVariables = (value) => {
+ value = value || '';
+ const variables = value.match(regexVariable) || [];
+ each(variables, (variable) => {
+ value = value.replace(variable, variable.replace('_.', '').replaceAll(' ', ''));
+ });
+ return value;
+};
+
+const transformInsomniaRequestItem = (request, index, allRequests) => {
+ const name = addSuffixToDuplicateName(request, index, allRequests);
+
+ const brunoRequestItem = {
+ uid: uuid(),
+ name,
+ type: 'http-request',
+ request: {
+ url: request.url,
+ method: request.method,
+ auth: {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ digest: null
+ },
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none',
+ json: null,
+ text: null,
+ xml: null,
+ formUrlEncoded: [],
+ multipartForm: []
+ }
+ }
+ };
+
+ each(request.headers, (header) => {
+ brunoRequestItem.request.headers.push({
+ uid: uuid(),
+ name: header.name,
+ value: header.value,
+ description: header.description,
+ enabled: !header.disabled
+ });
+ });
+
+ each(request.parameters, (param) => {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.name,
+ value: param.value,
+ description: param.description,
+ type: 'query',
+ enabled: !param.disabled
+ });
+ });
+
+ each(request.pathParameters, (param) => {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.name,
+ value: param.value,
+ description: '',
+ type: 'path',
+ enabled: true
+ });
+ });
+
+ const authType = get(request, 'authentication.type', '');
+
+ if (authType === 'basic') {
+ brunoRequestItem.request.auth.mode = 'basic';
+ brunoRequestItem.request.auth.basic = {
+ username: normalizeVariables(get(request, 'authentication.username', '')),
+ password: normalizeVariables(get(request, 'authentication.password', ''))
+ };
+ } else if (authType === 'bearer') {
+ brunoRequestItem.request.auth.mode = 'bearer';
+ brunoRequestItem.request.auth.bearer = {
+ token: normalizeVariables(get(request, 'authentication.token', ''))
+ };
+ }
+
+ const mimeType = get(request, 'body.mimeType', '').split(';')[0];
+
+ if (mimeType === 'application/json') {
+ brunoRequestItem.request.body.mode = 'json';
+ brunoRequestItem.request.body.json = request.body.text;
+ } else if (mimeType === 'application/x-www-form-urlencoded') {
+ brunoRequestItem.request.body.mode = 'formUrlEncoded';
+ each(request.body.params, (param) => {
+ brunoRequestItem.request.body.formUrlEncoded.push({
+ uid: uuid(),
+ name: param.name,
+ value: param.value,
+ description: param.description,
+ enabled: !param.disabled
+ });
+ });
+ } else if (mimeType === 'multipart/form-data') {
+ brunoRequestItem.request.body.mode = 'multipartForm';
+ each(request.body.params, (param) => {
+ brunoRequestItem.request.body.multipartForm.push({
+ uid: uuid(),
+ type: 'text',
+ name: param.name,
+ value: param.value,
+ description: param.description,
+ enabled: !param.disabled
+ });
+ });
+ } else if (mimeType === 'text/plain') {
+ brunoRequestItem.request.body.mode = 'text';
+ brunoRequestItem.request.body.text = request.body.text;
+ } else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
+ brunoRequestItem.request.body.mode = 'xml';
+ brunoRequestItem.request.body.xml = request.body.text;
+ } else if (mimeType === 'application/graphql') {
+ brunoRequestItem.type = 'graphql-request';
+ brunoRequestItem.request.body.mode = 'graphql';
+ brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
+ }
+
+ return brunoRequestItem;
+};
+
+const isInsomniaV5Export = (data) => {
+ // V5 format has a type property at the root level
+ if (data.type && data.type.startsWith('collection.insomnia.rest/5')) {
+ return true;
+ }
+ return false;
+};
+
+const parseInsomniaV5Collection = (data) => {
+ const brunoCollection = {
+ name: data.name || 'Untitled Collection',
+ uid: uuid(),
+ version: '1',
+ items: [],
+ environments: []
+ };
+
+ try {
+ // Parse the collection items
+ const parseCollectionItems = (items, allItems = []) => {
+ if (!Array.isArray(items)) {
+ throw new Error('Invalid items format: expected array');
+ }
+
+ return items.map((item, index) => {
+ if (!item) {
+ return null;
+ }
+
+ // In v5, requests might be defined with method property or meta.type
+ if (item.method && item.url) {
+ const request = {
+ _id: item.meta?.id || uuid(),
+ name: item.name || 'Untitled Request',
+ url: item.url || '',
+ method: item.method || '',
+ headers: item.headers || [],
+ parameters: item.parameters || [],
+ pathParameters: item.pathParameters || [],
+ authentication: item.authentication || {},
+ body: item.body || {}
+ };
+ return transformInsomniaRequestItem(request, index, allItems);
+ } else if (item.children && Array.isArray(item.children)) {
+ // Process folder
+ return {
+ uid: uuid(),
+ name: item.name || 'Untitled Folder',
+ type: 'folder',
+ items: parseCollectionItems(item.children, item.children)
+ };
+ }
+ return null;
+ }).filter(Boolean);
+ };
+
+ if (data.collection && Array.isArray(data.collection)) {
+ brunoCollection.items = parseCollectionItems(data.collection, data.collection);
+ }
+
+ // Parse environments if available
+ if (data.environments) {
+ // Handle environments implementation if needed
+ }
+
+ return brunoCollection;
+ } catch (err) {
+ console.error('Error parsing collection:', err);
+ throw new Error('An error occurred while parsing the Insomnia v5 collection: ' + err.message);
+ }
+};
+
+const parseInsomniaCollection = (data) => {
+ const brunoCollection = {
+ name: '',
+ uid: uuid(),
+ version: '1',
+ items: [],
+ environments: []
+ };
+
+ try {
+ const insomniaResources = get(data, 'resources', []);
+ const insomniaCollection = insomniaResources.find((resource) => resource._type === 'workspace');
+
+ if (!insomniaCollection) {
+ throw new Error('Collection not found inside Insomnia export');
+ }
+
+ brunoCollection.name = insomniaCollection.name;
+
+ const requestsAndFolders =
+ insomniaResources.filter((resource) => resource._type === 'request' || resource._type === 'request_group') ||
+ [];
+
+ function createFolderStructure(resources, parentId = null) {
+ const requestGroups =
+ resources.filter((resource) => resource._type === 'request_group' && resource.parentId === parentId) || [];
+ const requests = resources.filter((resource) => resource._type === 'request' && resource.parentId === parentId);
+
+ const folders = requestGroups.map((folder, index, allFolder) => {
+ const name = addSuffixToDuplicateName(folder, index, allFolder);
+ const requests = resources.filter(
+ (resource) => resource._type === 'request' && resource.parentId === folder._id
+ );
+
+ return {
+ uid: uuid(),
+ name,
+ type: 'folder',
+ items: createFolderStructure(resources, folder._id).concat(
+ requests.filter(r => r.parentId === folder._id).map(transformInsomniaRequestItem)
+ )
+ };
+ });
+
+ return folders.concat(requests.map(transformInsomniaRequestItem));
+ }
+
+ brunoCollection.items = createFolderStructure(requestsAndFolders, insomniaCollection._id);
+ return brunoCollection;
+ } catch (err) {
+ console.error('Error parsing collection:', err);
+ throw new Error('An error occurred while parsing the Insomnia collection: ' + err.message);
+ }
+};
+
+export const insomniaToBruno = (insomniaCollection) => {
+ try {
+ if(typeof insomniaCollection !== 'object') {
+ insomniaCollection = jsyaml.load(insomniaCollection);
+ }
+ let collection;
+ if (isInsomniaV5Export(insomniaCollection)) {
+ collection = parseInsomniaV5Collection(insomniaCollection);
+ } else {
+ collection = parseInsomniaCollection(insomniaCollection);
+ }
+
+ const transformedCollection = transformItemsInCollection(collection);
+ const hydratedCollection = hydrateSeqInCollection(transformedCollection);
+ const validatedCollection = validateSchema(hydratedCollection);
+ return validatedCollection;
+ } catch (err) {
+ console.error(err);
+ throw new Error('Import collection failed: ' + err.message);
+ }
+};
+
+export default insomniaToBruno;
diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js
new file mode 100644
index 000000000..f1dec55b4
--- /dev/null
+++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js
@@ -0,0 +1,449 @@
+import each from 'lodash/each';
+import get from 'lodash/get';
+import jsyaml from 'js-yaml';
+import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
+
+const ensureUrl = (url) => {
+ // removing multiple slashes after the protocol if it exists, or after the beginning of the string otherwise
+ return url.replace(/([^:])\/{2,}/g, '$1/');
+};
+
+const buildEmptyJsonBody = (bodySchema, visited = new Map()) => {
+ // Check for circular references
+ if (visited.has(bodySchema)) {
+ return {};
+ }
+
+ // Add this schema to visited map
+ visited.set(bodySchema, true);
+
+ let _jsonBody = {};
+ each(bodySchema.properties || {}, (prop, name) => {
+ if (prop.type === 'object') {
+ _jsonBody[name] = buildEmptyJsonBody(prop, visited);
+ } else if (prop.type === 'array') {
+ if (prop.items && prop.items.type === 'object') {
+ _jsonBody[name] = [buildEmptyJsonBody(prop.items, visited)];
+ } else {
+ _jsonBody[name] = [];
+ }
+ } else {
+ _jsonBody[name] = '';
+ }
+ });
+ return _jsonBody;
+};
+
+const transformOpenapiRequestItem = (request) => {
+ let _operationObject = request.operationObject;
+
+ let operationName = _operationObject.summary || _operationObject.operationId || _operationObject.description;
+ if (!operationName) {
+ operationName = `${request.method} ${request.path}`;
+ }
+
+ // replace OpenAPI links in path by Bruno variables
+ let path = request.path.replace(/{([a-zA-Z]+)}/g, `{{${_operationObject.operationId}_$1}}`);
+
+ const brunoRequestItem = {
+ uid: uuid(),
+ name: operationName,
+ type: 'http-request',
+ request: {
+ url: ensureUrl(request.global.server + path),
+ method: request.method.toUpperCase(),
+ auth: {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ digest: null
+ },
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none',
+ json: null,
+ text: null,
+ xml: null,
+ formUrlEncoded: [],
+ multipartForm: []
+ },
+ script: {
+ res: null
+ }
+ }
+ };
+
+ each(_operationObject.parameters || [], (param) => {
+ if (param.in === 'query') {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.name,
+ value: '',
+ description: param.description || '',
+ enabled: param.required,
+ type: 'query'
+ });
+ } else if (param.in === 'path') {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.name,
+ value: '',
+ description: param.description || '',
+ enabled: param.required,
+ type: 'path'
+ });
+ } else if (param.in === 'header') {
+ brunoRequestItem.request.headers.push({
+ uid: uuid(),
+ name: param.name,
+ value: '',
+ description: param.description || '',
+ enabled: param.required
+ });
+ }
+ });
+
+ let auth;
+ // allow operation override
+ if (_operationObject.security && _operationObject.security.length > 0) {
+ let schemeName = Object.keys(_operationObject.security[0])[0];
+ auth = request.global.security.getScheme(schemeName);
+ } else if (request.global.security.supported.length > 0) {
+ auth = request.global.security.supported[0];
+ }
+
+ if (auth) {
+ if (auth.type === 'http' && auth.scheme === 'basic') {
+ brunoRequestItem.request.auth.mode = 'basic';
+ brunoRequestItem.request.auth.basic = {
+ username: '{{username}}',
+ password: '{{password}}'
+ };
+ } else if (auth.type === 'http' && auth.scheme === 'bearer') {
+ brunoRequestItem.request.auth.mode = 'bearer';
+ brunoRequestItem.request.auth.bearer = {
+ token: '{{token}}'
+ };
+ } else if (auth.type === 'apiKey' && auth.in === 'header') {
+ brunoRequestItem.request.headers.push({
+ uid: uuid(),
+ name: auth.name,
+ value: '{{apiKey}}',
+ description: 'Authentication header',
+ enabled: true
+ });
+ }
+ }
+
+ // TODO: handle allOf/anyOf/oneOf
+ if (_operationObject.requestBody) {
+ let content = get(_operationObject, 'requestBody.content', {});
+ let mimeType = Object.keys(content)[0];
+ let body = content[mimeType] || {};
+ let bodySchema = body.schema;
+ if (mimeType === 'application/json') {
+ brunoRequestItem.request.body.mode = 'json';
+ if (bodySchema && bodySchema.type === 'object') {
+ let _jsonBody = buildEmptyJsonBody(bodySchema);
+ brunoRequestItem.request.body.json = JSON.stringify(_jsonBody, null, 2);
+ }
+ if (bodySchema && bodySchema.type === 'array') {
+ brunoRequestItem.request.body.json = JSON.stringify([buildEmptyJsonBody(bodySchema.items)], null, 2);
+ }
+ } else if (mimeType === 'application/x-www-form-urlencoded') {
+ brunoRequestItem.request.body.mode = 'formUrlEncoded';
+ if (bodySchema && bodySchema.type === 'object') {
+ each(bodySchema.properties || {}, (prop, name) => {
+ brunoRequestItem.request.body.formUrlEncoded.push({
+ uid: uuid(),
+ name: name,
+ value: '',
+ description: prop.description || '',
+ enabled: true
+ });
+ });
+ }
+ } else if (mimeType === 'multipart/form-data') {
+ brunoRequestItem.request.body.mode = 'multipartForm';
+ if (bodySchema && bodySchema.type === 'object') {
+ each(bodySchema.properties || {}, (prop, name) => {
+ brunoRequestItem.request.body.multipartForm.push({
+ uid: uuid(),
+ type: 'text',
+ name: name,
+ value: '',
+ description: prop.description || '',
+ enabled: true
+ });
+ });
+ }
+ } else if (mimeType === 'text/plain') {
+ brunoRequestItem.request.body.mode = 'text';
+ brunoRequestItem.request.body.text = '';
+ } else if (mimeType === 'text/xml') {
+ brunoRequestItem.request.body.mode = 'xml';
+ brunoRequestItem.request.body.xml = '';
+ }
+ }
+
+ // build the extraction scripts from responses that have links
+ // https://swagger.io/docs/specification/links/
+ let script = [];
+ each(_operationObject.responses || [], (response, responseStatus) => {
+ if (Object.hasOwn(response, 'links')) {
+ // only extract if the status code matches the response
+ script.push(`if (res.status === ${responseStatus}) {`);
+ each(response.links, (link) => {
+ each(link.parameters || [], (expression, parameter) => {
+ let value = openAPIRuntimeExpressionToScript(expression);
+ script.push(` bru.setVar('${link.operationId}_${parameter}', ${value});`);
+ });
+ });
+ script.push(`}`);
+ }
+ });
+ if (script.length > 0) {
+ brunoRequestItem.request.script.res = script.join('\n');
+ }
+
+ return brunoRequestItem;
+};
+
+const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
+ if (!spec || typeof spec !== 'object') {
+ return spec;
+ }
+
+ if (cache.has(spec)) {
+ return cache.get(spec);
+ }
+
+ if (Array.isArray(spec)) {
+ return spec.map(item => resolveRefs(item, components, cache));
+ }
+
+ if ('$ref' in spec) {
+ const refPath = spec.$ref;
+
+ if (cache.has(refPath)) {
+ return cache.get(refPath);
+ }
+
+ if (refPath.startsWith('#/components/')) {
+ const refKeys = refPath.replace('#/components/', '').split('/');
+ let ref = components;
+
+ for (const key of refKeys) {
+ if (ref && ref[key]) {
+ ref = ref[key];
+ } else {
+ return spec;
+ }
+ }
+
+ cache.set(refPath, {});
+ const resolved = resolveRefs(ref, components, cache);
+ cache.set(refPath, resolved);
+ return resolved;
+ }
+ return spec;
+ }
+
+ const resolved = {};
+ cache.set(spec, resolved);
+
+ for (const [key, value] of Object.entries(spec)) {
+ resolved[key] = resolveRefs(value, components, cache);
+ }
+
+ return resolved;
+};
+
+const groupRequestsByTags = (requests) => {
+ let _groups = {};
+ let ungrouped = [];
+ each(requests, (request) => {
+ let tags = request.operationObject.tags || [];
+ if (tags.length > 0) {
+ let tag = tags[0].trim(); // take first tag and trim whitespace
+
+ if (tag) {
+ if (!_groups[tag]) {
+ _groups[tag] = [];
+ }
+ _groups[tag].push(request);
+ } else {
+ ungrouped.push(request);
+ }
+ } else {
+ ungrouped.push(request);
+ }
+ });
+
+ let groups = Object.keys(_groups).map((groupName) => {
+ return {
+ name: groupName,
+ requests: _groups[groupName]
+ };
+ });
+
+ return [groups, ungrouped];
+};
+
+const getDefaultUrl = (serverObject) => {
+ let url = serverObject.url;
+ if (serverObject.variables) {
+ each(serverObject.variables, (variable, variableName) => {
+ let sub = variable.default || (variable.enum ? variable.enum[0] : `{{${variableName}}}`);
+ url = url.replace(`{${variableName}}`, sub);
+ });
+ }
+ return url.endsWith('/') ? url.slice(0, -1) : url;
+};
+
+const getSecurity = (apiSpec) => {
+ let defaultSchemes = apiSpec.security || [];
+
+ let securitySchemes = get(apiSpec, 'components.securitySchemes', {});
+ if (Object.keys(securitySchemes) === 0) {
+ return {
+ supported: []
+ };
+ }
+
+ return {
+ supported: defaultSchemes.map((scheme) => {
+ var schemeName = Object.keys(scheme)[0];
+ return securitySchemes[schemeName];
+ }),
+ schemes: securitySchemes,
+ getScheme: (schemeName) => {
+ return securitySchemes[schemeName];
+ }
+ };
+};
+
+const openAPIRuntimeExpressionToScript = (expression) => {
+ // see https://swagger.io/docs/specification/links/#runtime-expressions
+ if (expression === '$response.body') {
+ return 'res.body';
+ } else if (expression.startsWith('$response.body#')) {
+ let pointer = expression.substring(15);
+ // could use https://www.npmjs.com/package/json-pointer for better support
+ return `res.body${pointer.replace('/', '.')}`;
+ }
+ return expression;
+};
+
+export const parseOpenApiCollection = (data) => {
+ const brunoCollection = {
+ name: '',
+ uid: uuid(),
+ version: '1',
+ items: [],
+ environments: []
+ };
+ try {
+ const collectionData = resolveRefs(data);
+ if (!collectionData) {
+ throw new Error('Invalid OpenAPI collection. Failed to resolve refs.');
+ return;
+ }
+
+ // Currently parsing of openapi spec is "do your best", that is
+ // allows "invalid" openapi spec
+
+ // Assumes v3 if not defined. v2 is not supported yet
+ if (collectionData.openapi && !collectionData.openapi.startsWith('3')) {
+ throw new Error('Only OpenAPI v3 is supported currently.');
+ return;
+ }
+
+ // TODO what if info.title not defined?
+ brunoCollection.name = collectionData.info.title;
+ let servers = collectionData.servers || [];
+
+ // Create environments based on the servers
+ servers.forEach((server, index) => {
+ let baseUrl = getDefaultUrl(server);
+ let environmentName = server.description ? server.description : `Environment ${index + 1}`;
+
+ brunoCollection.environments.push({
+ uid: uuid(),
+ name: environmentName,
+ variables: [
+ {
+ uid: uuid(),
+ name: 'baseUrl',
+ value: baseUrl,
+ type: 'text',
+ enabled: true,
+ secret: false
+ },
+ ]
+ });
+ });
+
+ let securityConfig = getSecurity(collectionData);
+
+ let allRequests = Object.entries(collectionData.paths)
+ .map(([path, methods]) => {
+ return Object.entries(methods)
+ .filter(([method, op]) => {
+ return ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(
+ method.toLowerCase()
+ );
+ })
+ .map(([method, operationObject]) => {
+ return {
+ method: method,
+ path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
+ operationObject: operationObject,
+ global: {
+ server: '{{baseUrl}}',
+ security: securityConfig
+ }
+ };
+ });
+ })
+ .reduce((acc, val) => acc.concat(val), []); // flatten
+
+ let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
+ let brunoFolders = groups.map((group) => {
+ return {
+ uid: uuid(),
+ name: group.name,
+ type: 'folder',
+ items: group.requests.map(transformOpenapiRequestItem)
+ };
+ });
+
+ let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
+ let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
+ brunoCollection.items = brunoCollectionItems;
+ return brunoCollection;
+ } catch (err) {
+ console.error(err);
+ throw new Error('An error occurred while parsing the OpenAPI collection');
+ }
+};
+
+export const openApiToBruno = (openApiSpecification) => {
+ try {
+ if(typeof openApiSpecification !== 'object') {
+ openApiSpecification = jsyaml.load(openApiSpecification);
+ }
+
+ const collection = parseOpenApiCollection(openApiSpecification);
+ const transformedCollection = transformItemsInCollection(collection);
+ const hydratedCollection = hydrateSeqInCollection(transformedCollection);
+ const validatedCollection = validateSchema(hydratedCollection);
+ return validatedCollection
+ } catch (err) {
+ console.error(err);
+ throw new Error('Import collection failed');
+ }
+};
+
+export default openApiToBruno;
diff --git a/packages/bruno-converters/src/postman/bruno-to-postman.js b/packages/bruno-converters/src/postman/bruno-to-postman.js
new file mode 100644
index 000000000..9db0e40ae
--- /dev/null
+++ b/packages/bruno-converters/src/postman/bruno-to-postman.js
@@ -0,0 +1,367 @@
+import map from 'lodash/map';
+import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../common';
+
+/**
+ * Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
+ *
+ * @param {string} url - The raw URL to be transformed.
+ * @param {Object} params - The params object.
+ * @returns {Object|null} An object containing the URL's protocol, host, path, query, and variables, or {} if an error occurs.
+ */
+export const transformUrl = (url, params) => {
+ if (typeof url !== 'string' || !url.trim()) {
+ url = "";
+ console.error("Invalid URL input:", url);
+ }
+
+ const urlRegexPatterns = {
+ protocolAndRestSeparator: /:\/\//,
+ hostAndPathSeparator: /\/(.+)/,
+ domainSegmentSeparator: /\./,
+ pathSegmentSeparator: /\//,
+ queryStringSeparator: /\?/
+ };
+
+ const postmanUrl = { raw: url };
+
+ /**
+ * Splits a URL into its protocol, host and path.
+ *
+ * @param {string} url - The URL to be split.
+ * @returns {Object} An object containing the protocol and the raw host/path string.
+ */
+ const splitUrl = (url) => {
+ const urlParts = url.split(urlRegexPatterns.protocolAndRestSeparator);
+ if (urlParts.length === 1) {
+ return { protocol: '', rawHostAndPath: urlParts[0] };
+ } else if (urlParts.length === 2) {
+ const [hostAndPath, _] = urlParts[1].split(urlRegexPatterns.queryStringSeparator);
+ return { protocol: urlParts[0], rawHostAndPath: hostAndPath };
+ } else {
+ throw new Error(`Invalid URL format: ${url}`);
+ }
+ };
+
+ /**
+ * Splits the host and path from a raw host/path string.
+ *
+ * @param {string} rawHostAndPath - The raw host and path string to be split.
+ * @returns {Object} An object containing the host and path.
+ */
+ const splitHostAndPath = (rawHostAndPath) => {
+ const [host, path = ''] = rawHostAndPath.split(urlRegexPatterns.hostAndPathSeparator);
+ return { host, path };
+ };
+
+ try {
+ const { protocol, rawHostAndPath } = splitUrl(url);
+ postmanUrl.protocol = protocol;
+
+ const { host, path } = splitHostAndPath(rawHostAndPath);
+ postmanUrl.host = host ? host.split(urlRegexPatterns.domainSegmentSeparator) : [];
+ postmanUrl.path = path ? path.split(urlRegexPatterns.pathSegmentSeparator) : [];
+ } catch (error) {
+ console.error(error.message);
+ return {};
+ }
+
+ // Construct query params.
+ postmanUrl.query = params
+ .filter((param) => param.type === 'query')
+ .map(({ name, value, description }) => ({ key: name, value, description }));
+
+ // Construct path params.
+ postmanUrl.variable = params
+ .filter((param) => param.type === 'path')
+ .map(({ name, value, description }) => ({ key: name, value, description }));
+
+ return postmanUrl;
+};
+
+/**
+ * Collapses multiple consecutive slashes (`//`) into a single slash, while skipping the protocol (e.g., `http://` or `https://`).
+ *
+ * @param {String} url - A URL string
+ * @returns {String} The sanitized URL
+ *
+ */
+const collapseDuplicateSlashes = (url) => {
+ return url.replace(/(? {
+ let sanitizedUrl = collapseDuplicateSlashes(url.replace(/\\/g, '//'));
+ return sanitizedUrl;
+};
+
+export const brunoToPostman = (collection) => {
+ delete collection.uid;
+ delete collection.processEnvVariables;
+ deleteUidsInItems(collection.items);
+ deleteUidsInEnvs(collection.environments);
+ deleteSecretsInEnvs(collection.environments);
+
+ const generateInfoSection = () => {
+ return {
+ name: collection.name,
+ description: collection.root?.docs,
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ };
+ };
+
+ const generateCollectionVars = (collection) => {
+ const pattern = /{{[^{}]+}}/g;
+ let listOfVars = [];
+
+ const findOccurrences = (obj, results) => {
+ if (typeof obj === 'object') {
+ if (Array.isArray(obj)) {
+ obj.forEach((item) => findOccurrences(item, results));
+ } else {
+ for (const key in obj) {
+ findOccurrences(obj[key], results);
+ }
+ }
+ } else if (typeof obj === 'string') {
+ obj.replace(pattern, (match) => {
+ results.push(match.replace(/{{|}}/g, ''));
+ });
+ }
+ };
+
+ findOccurrences(collection, listOfVars);
+
+ const finalArrayOfVars = [...new Set(listOfVars)];
+
+ return finalArrayOfVars.map((variable) => ({
+ key: variable,
+ value: '',
+ type: 'default'
+ }));
+ };
+
+ const generateEventSection = (item) => {
+ const eventArray = [];
+ if (item?.request?.tests?.length) {
+ eventArray.push({
+ listen: 'test',
+ script: {
+ exec: item.request.tests.split('\n')
+ // type: 'text/javascript'
+ }
+ });
+ }
+ if (item?.request?.script?.req) {
+ eventArray.push({
+ listen: 'prerequest',
+ script: {
+ exec: item.request.script.req.split('\n')
+ // type: 'text/javascript'
+ }
+ });
+ }
+ return eventArray;
+ };
+
+ const generateHeaders = (headersArray) => {
+ if (!headersArray || !Array.isArray(headersArray)) {
+ return [];
+ }
+ return map(headersArray, (item) => {
+ return {
+ key: item.name || '',
+ value: item.value || '',
+ disabled: !item.enabled,
+ type: 'default'
+ };
+ });
+ };
+
+ const generateBody = (body) => {
+ if (!body || !body.mode) {
+ return {
+ mode: 'raw',
+ raw: ''
+ };
+ }
+
+ switch (body.mode) {
+ case 'formUrlEncoded':
+ return {
+ mode: 'urlencoded',
+ urlencoded: map(body.formUrlEncoded || [], (bodyItem) => {
+ return {
+ key: bodyItem.name || '',
+ value: bodyItem.value || '',
+ disabled: !bodyItem.enabled,
+ type: 'default'
+ };
+ })
+ };
+ case 'multipartForm':
+ return {
+ mode: 'formdata',
+ formdata: map(body.multipartForm || [], (bodyItem) => {
+ return {
+ key: bodyItem.name || '',
+ value: bodyItem.value || '',
+ disabled: !bodyItem.enabled,
+ type: 'default'
+ };
+ })
+ };
+ case 'json':
+ return {
+ mode: 'raw',
+ raw: body.json || '',
+ options: {
+ raw: {
+ language: 'json'
+ }
+ }
+ };
+ case 'xml':
+ return {
+ mode: 'raw',
+ raw: body.xml || '',
+ options: {
+ raw: {
+ language: 'xml'
+ }
+ }
+ };
+ case 'text':
+ return {
+ mode: 'raw',
+ raw: body.text || '',
+ options: {
+ raw: {
+ language: 'text'
+ }
+ }
+ };
+ case 'graphql':
+ return {
+ mode: 'graphql',
+ graphql: body.graphql || {}
+ };
+ default:
+ return {
+ mode: 'raw',
+ raw: ''
+ };
+ }
+ };
+
+ const generateAuth = (itemAuth) => {
+ switch (itemAuth?.mode) {
+ case 'bearer':
+ return {
+ type: 'bearer',
+ bearer: {
+ key: 'token',
+ value: itemAuth.bearer?.token || '',
+ type: 'string'
+ }
+ };
+ case 'basic': {
+ return {
+ type: 'basic',
+ basic: [
+ {
+ key: 'password',
+ value: itemAuth.basic?.password || '',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: itemAuth.basic?.username || '',
+ type: 'string'
+ }
+ ]
+ };
+ }
+ case 'apikey': {
+ return {
+ type: 'apikey',
+ apikey: [
+ {
+ key: 'key',
+ value: itemAuth.apikey?.key || '',
+ type: 'string'
+ },
+ {
+ key: 'value',
+ value: itemAuth.apikey?.value || '',
+ type: 'string'
+ }
+ ]
+ };
+ }
+ default: {
+ return {
+ type: 'noauth'
+ };
+ }
+ }
+ };
+
+ const generateRequestSection = (itemRequest) => {
+ if (!itemRequest) {
+ return {};
+ }
+
+ const requestObject = {
+ method: itemRequest.method || 'GET',
+ header: generateHeaders(itemRequest.headers),
+ auth: generateAuth(itemRequest.auth),
+ description: itemRequest.docs || '',
+ // We sanitize the URL to make sure it's in the right format before passing it to the transformUrl func. This means changing backslashes to forward slashes and reducing multiple slashes to a single one, except in the protocol part.
+ url: transformUrl(sanitizeUrl(itemRequest.url || ''), itemRequest.params || [])
+ };
+
+ if (itemRequest.body && itemRequest.body.mode !== 'none') {
+ requestObject.body = generateBody(itemRequest.body);
+ }
+ return requestObject;
+ };
+
+ const generateItemSection = (itemsArray) => {
+ if (!itemsArray || !Array.isArray(itemsArray)) {
+ return [];
+ }
+
+ return map(itemsArray, (item) => {
+ if (!item) {
+ return null;
+ }
+
+ if (item.type === 'folder') {
+ return {
+ name: item.name || 'Untitled Folder',
+ item: item.items && item.items.length ? generateItemSection(item.items) : []
+ };
+ } else {
+ return {
+ name: item.name || 'Untitled Request',
+ event: generateEventSection(item),
+ request: generateRequestSection(item.request)
+ };
+ }
+ });
+ };
+ const collectionToExport = {};
+ collectionToExport.info = generateInfoSection();
+ collectionToExport.item = generateItemSection(collection.items);
+ collectionToExport.variable = generateCollectionVars(collection);
+ return collectionToExport;
+};
+
+export default brunoToPostman;
diff --git a/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js b/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js
new file mode 100644
index 000000000..52d60b08b
--- /dev/null
+++ b/packages/bruno-converters/src/postman/postman-env-to-bruno-env.js
@@ -0,0 +1,44 @@
+import each from 'lodash/each';
+import { invalidVariableCharacterRegex } from '../constants';
+import { uuid } from '../common';
+
+const isSecret = (type) => {
+ return type === 'secret';
+};
+
+const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
+ brunoEnvironment.variables = brunoEnvironment.variables || [];
+
+ each(values, (i) => {
+ const brunoEnvironmentVariable = {
+ uid: uuid(),
+ name: i.key.replace(invalidVariableCharacterRegex, '_'),
+ value: i.value,
+ enabled: i.enabled,
+ secret: isSecret(i.type)
+ };
+
+ brunoEnvironment.variables.push(brunoEnvironmentVariable);
+ });
+};
+
+const importPostmanEnvironment = (environment) => {
+ const brunoEnvironment = {
+ name: environment.name,
+ variables: []
+ };
+
+ importPostmanEnvironmentVariables(brunoEnvironment, environment.values);
+ return brunoEnvironment;
+};
+
+export const postmanToBrunoEnvironment = (postmanEnvironment) => {
+ try {
+ return importPostmanEnvironment(postmanEnvironment);
+ } catch (err) {
+ console.log(err);
+ throw new Error('Unable to parse the postman environment json file');
+ }
+};
+
+export default postmanToBrunoEnvironment;
diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js
new file mode 100644
index 000000000..bef21b46e
--- /dev/null
+++ b/packages/bruno-converters/src/postman/postman-to-bruno.js
@@ -0,0 +1,667 @@
+import get from 'lodash/get';
+import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
+import each from 'lodash/each';
+import postmanTranslation from './postman-translations';
+import { invalidVariableCharacterRegex } from '../constants/index';
+
+const parseGraphQLRequest = (graphqlSource) => {
+ try {
+ let queryResultObject = {
+ query: '',
+ variables: ''
+ };
+
+ if (typeof graphqlSource === 'string') {
+ graphqlSource = JSON.parse(graphqlSource);
+ }
+
+ if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
+ queryResultObject.variables = graphqlSource.variables;
+ }
+
+ if (graphqlSource.hasOwnProperty('query') && graphqlSource.query !== '') {
+ queryResultObject.query = graphqlSource.query;
+ }
+
+ return queryResultObject;
+ } catch (e) {
+ return {
+ query: '',
+ variables: ''
+ };
+ }
+};
+
+const isItemAFolder = (item) => {
+ return !item.request;
+};
+
+const convertV21Auth = (array) => {
+ return array.reduce((accumulator, currentValue) => {
+ accumulator[currentValue.key] = currentValue.value;
+ return accumulator;
+ }, {});
+};
+
+const constructUrlFromParts = (url) => {
+ if (!url) return '';
+
+ const { protocol = 'http', host, path, port, query, hash } = url || {};
+ const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
+ const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
+ const portStr = port ? `:${port}` : '';
+ const queryStr =
+ query && Array.isArray(query) && query.length > 0
+ ? `?${query
+ .filter((q) => q && q.key)
+ .map((q) => `${q.key}=${q.value || ''}`)
+ .join('&')}`
+ : '';
+ const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
+ return urlStr;
+};
+
+const constructUrl = (url) => {
+ if (!url) return '';
+
+ if (typeof url === 'string') {
+ return url;
+ }
+
+ if (typeof url === 'object') {
+ const { raw } = url;
+
+ if (raw && typeof raw === 'string') {
+ // If the raw URL contains url-fragments remove it
+ if (raw.includes('#')) {
+ return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
+ }
+ return raw;
+ }
+
+ // If no raw value exists, construct the URL from parts
+ return constructUrlFromParts(url);
+ }
+
+ return '';
+};
+
+const importScriptsFromEvents = (events, requestObject) => {
+ events.forEach((event) => {
+ if (event.script && event.script.exec) {
+ if (event.listen === 'prerequest') {
+ if (!requestObject.script) {
+ requestObject.script = {};
+ }
+
+ if (event.script.exec && event.script.exec.length > 0) {
+ requestObject.script.req = postmanTranslation(event.script.exec)
+ } else {
+ requestObject.script.req = '';
+ console.warn('Unexpected event.script.exec type', typeof event.script.exec);
+ }
+ }
+
+ if (event.listen === 'test') {
+ if (!requestObject.script) {
+ requestObject.script = {};
+ }
+
+ if (event.script.exec && event.script.exec.length > 0) {
+ requestObject.script.res = postmanTranslation(event.script.exec)
+ } else {
+ requestObject.script.res = '';
+ console.warn('Unexpected event.script.exec type', typeof event.script.exec);
+ }
+ }
+ }
+ });
+};
+
+const importCollectionLevelVariables = (variables, requestObject) => {
+ const vars = variables.map((v) => ({
+ uid: uuid(),
+ name: v.key.replace(invalidVariableCharacterRegex, '_'),
+ value: v.value,
+ enabled: true
+ }));
+
+ requestObject.vars.req = vars;
+};
+
+const processAuth = (auth, requestObject) => {
+ if (!auth || !auth.type || auth.type === 'noauth') {
+ return;
+ }
+
+ let authValues = auth[auth.type];
+ if (Array.isArray(authValues)) {
+ authValues = convertV21Auth(authValues);
+ }
+
+ if (auth.type === 'basic') {
+ requestObject.auth.mode = 'basic';
+ requestObject.auth.basic = {
+ username: authValues.username || '',
+ password: authValues.password || ''
+ };
+ } else if (auth.type === 'bearer') {
+ requestObject.auth.mode = 'bearer';
+ requestObject.auth.bearer = {
+ token: authValues.token || ''
+ };
+ } else if (auth.type === 'awsv4') {
+ requestObject.auth.mode = 'awsv4';
+ requestObject.auth.awsv4 = {
+ accessKeyId: authValues.accessKey || '',
+ secretAccessKey: authValues.secretKey || '',
+ sessionToken: authValues.sessionToken || '',
+ service: authValues.service || '',
+ region: authValues.region || '',
+ profileName: ''
+ };
+ } else if (auth.type === 'apikey') {
+ requestObject.auth.mode = 'apikey';
+ requestObject.auth.apikey = {
+ key: authValues.key || '',
+ value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
+ placement: 'header' //By default we are placing the apikey values in headers!
+ };
+ } else if (auth.type === 'digest') {
+ requestObject.auth.mode = 'digest';
+ requestObject.auth.digest = {
+ username: authValues.username || '',
+ password: authValues.password || ''
+ };
+ } else if (auth.type === 'oauth2') {
+ const findValueUsingKey = (key) => {
+ return authValues[key] || '';
+ };
+ const oauth2GrantTypeMaps = {
+ authorization_code_with_pkce: 'authorization_code',
+ authorization_code: 'authorization_code',
+ client_credentials: 'client_credentials',
+ password_credentials: 'password_credentials'
+ };
+ const grantType = oauth2GrantTypeMaps[findValueUsingKey('grant_type')] || 'authorization_code';
+
+ requestObject.auth.mode = 'oauth2';
+ if (grantType === 'authorization_code') {
+ requestObject.auth.oauth2 = {
+ grantType: 'authorization_code',
+ authorizationUrl: findValueUsingKey('authUrl'),
+ callbackUrl: findValueUsingKey('redirect_uri'),
+ accessTokenUrl: findValueUsingKey('accessTokenUrl'),
+ refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
+ clientId: findValueUsingKey('clientId'),
+ clientSecret: findValueUsingKey('clientSecret'),
+ scope: findValueUsingKey('scope'),
+ state: findValueUsingKey('state'),
+ pkce: Boolean(findValueUsingKey('grant_type') == 'authorization_code_with_pkce'),
+ tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
+ credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
+ };
+ } else if (grantType === 'password_credentials') {
+ requestObject.auth.oauth2 = {
+ grantType: 'password',
+ accessTokenUrl: findValueUsingKey('accessTokenUrl'),
+ refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
+ username: findValueUsingKey('username'),
+ password: findValueUsingKey('password'),
+ clientId: findValueUsingKey('clientId'),
+ clientSecret: findValueUsingKey('clientSecret'),
+ scope: findValueUsingKey('scope'),
+ state: findValueUsingKey('state'),
+ tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
+ credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
+ };
+ } else if (grantType === 'client_credentials') {
+ requestObject.auth.oauth2 = {
+ grantType: 'client_credentials',
+ accessTokenUrl: findValueUsingKey('accessTokenUrl'),
+ refreshTokenUrl: findValueUsingKey('refreshTokenUrl'),
+ clientId: findValueUsingKey('clientId'),
+ clientSecret: findValueUsingKey('clientSecret'),
+ scope: findValueUsingKey('scope'),
+ state: findValueUsingKey('state'),
+ tokenPlacement: findValueUsingKey('addTokenTo') == 'header' ? 'header' : 'url',
+ credentialsPlacement: findValueUsingKey('client_authentication') == 'body' ? 'body' : 'basic_auth_header'
+ };
+ }
+ } else {
+ console.warn('Unexpected auth.type', auth.type);
+ }
+};
+
+const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, { useWorkers = false } = {}, scriptMap)=> {
+ brunoParent.items = brunoParent.items || [];
+ const folderMap = {};
+ const requestMap = {};
+ const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
+
+ item.forEach((i, index) => {
+ if (isItemAFolder(i)) {
+ const baseFolderName = i.name || 'Untitled Folder';
+ let folderName = baseFolderName;
+ let count = 1;
+
+ while (folderMap[folderName]) {
+ folderName = `${baseFolderName}_${count}`;
+ count++;
+ }
+
+ const brunoFolderItem = {
+ uid: uuid(),
+ name: folderName,
+ type: 'folder',
+ items: [],
+ seq: index + 1,
+ root: {
+ docs: i.description || '',
+ meta: {
+ name: folderName
+ },
+ request: {
+ auth: {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ },
+ headers: [],
+ script: {},
+ tests: '',
+ vars: {}
+ }
+ }
+ };
+
+ brunoParent.items.push(brunoFolderItem);
+
+ // Folder level auth
+ if (i.auth) {
+ processAuth(i.auth, brunoFolderItem.root.request);
+ } else if (parentAuth) {
+ // Inherit parent auth if folder doesn't define its own
+ processAuth(parentAuth, brunoFolderItem.root.request);
+ }
+
+ if (i.item && i.item.length) {
+ importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, { useWorkers }, scriptMap);
+ }
+
+ if (i.event) {
+ if(useWorkers) {
+ scriptMap.set(brunoFolderItem.uid, {
+ events: i.event,
+ request: brunoFolderItem.root.request
+ });
+ } else {
+ importScriptsFromEvents(i.event, brunoFolderItem.root.request);
+ }
+ }
+
+ folderMap[folderName] = brunoFolderItem;
+
+ } else if (i.request) {
+ if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
+ console.warn('Unexpected request.method', i?.request?.method);
+ return;
+ }
+
+ const baseRequestName = i.name || 'Untitled Request';
+ let requestName = baseRequestName;
+ let count = 1;
+
+ while (requestMap[requestName]) {
+ requestName = `${baseRequestName}_${count}`;
+ count++;
+ }
+
+ const url = constructUrl(i.request.url);
+
+ const brunoRequestItem = {
+ uid: uuid(),
+ name: requestName,
+ type: 'http-request',
+ seq: index + 1,
+ request: {
+ url: url,
+ method: i?.request?.method?.toUpperCase(),
+ auth: {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ },
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none',
+ json: null,
+ text: null,
+ xml: null,
+ formUrlEncoded: [],
+ multipartForm: []
+ },
+ docs: i.request.description || ''
+ }
+ };
+
+ brunoParent.items.push(brunoRequestItem);
+
+ if (i.event) {
+ if(useWorkers) {
+ scriptMap.set(brunoRequestItem.uid, {
+ events: i.event,
+ request: brunoRequestItem.request
+ });
+ } else {
+ i.event.forEach((event) => {
+ if (event.listen === 'prerequest' && event.script && event.script.exec) {
+ if (!brunoRequestItem.request?.script) {
+ brunoRequestItem.request.script = {};
+ }
+ if (event.script.exec && event.script.exec.length > 0) {
+ brunoRequestItem.request.script.req = postmanTranslation(event.script.exec)
+ } else {
+ brunoRequestItem.request.script.req = '';
+ console.warn('Unexpected event.script.exec type', typeof event.script.exec);
+ }
+ }
+ if (event.listen === 'test' && event.script && event.script.exec) {
+ if (!brunoRequestItem.request?.script) {
+ brunoRequestItem.request.script = {};
+ }
+ if (event.script.exec && event.script.exec.length > 0) {
+ brunoRequestItem.request.script.res = postmanTranslation(event.script.exec)
+ } else {
+ brunoRequestItem.request.script.res = '';
+ console.warn('Unexpected event.script.exec type', typeof event.script.exec);
+ }
+ }
+
+ });
+ }
+ }
+
+ const bodyMode = get(i, 'request.body.mode');
+ if (bodyMode) {
+ if (bodyMode === 'formdata') {
+ brunoRequestItem.request.body.mode = 'multipartForm';
+
+ each(i.request.body.formdata, (param) => {
+ const isFile = param.type === 'file';
+ let value;
+ let type;
+
+ if (isFile) {
+ // If param.src is an array, keep it as it is.
+ // If param.src is a string, convert it into an array with a single element.
+ value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
+ type = 'file';
+ } else {
+ value = param.value;
+ type = 'text';
+ }
+
+ brunoRequestItem.request.body.multipartForm.push({
+ uid: uuid(),
+ type: type,
+ name: param.key,
+ value: value,
+ description: param.description,
+ enabled: !param.disabled
+ });
+ });
+ }
+
+ if (bodyMode === 'urlencoded') {
+ brunoRequestItem.request.body.mode = 'formUrlEncoded';
+ each(i.request.body.urlencoded, (param) => {
+ brunoRequestItem.request.body.formUrlEncoded.push({
+ uid: uuid(),
+ name: param.key,
+ value: param.value,
+ description: param.description,
+ enabled: !param.disabled
+ });
+ });
+ }
+
+ if (bodyMode === 'raw') {
+ let language = get(i, 'request.body.options.raw.language');
+ if (!language) {
+ language = searchLanguageByHeader(i.request.header);
+ }
+ if (language === 'json') {
+ brunoRequestItem.request.body.mode = 'json';
+ brunoRequestItem.request.body.json = i.request.body.raw;
+ } else if (language === 'xml') {
+ brunoRequestItem.request.body.mode = 'xml';
+ brunoRequestItem.request.body.xml = i.request.body.raw;
+ } else {
+ brunoRequestItem.request.body.mode = 'text';
+ brunoRequestItem.request.body.text = i.request.body.raw;
+ }
+ }
+ }
+
+ if (bodyMode === 'graphql') {
+ brunoRequestItem.type = 'graphql-request';
+ brunoRequestItem.request.body.mode = 'graphql';
+ brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
+ }
+
+ each(i.request.header, (header) => {
+ brunoRequestItem.request.headers.push({
+ uid: uuid(),
+ name: header.key,
+ value: header.value,
+ description: header.description,
+ enabled: !header.disabled
+ });
+ });
+
+ // Handle request-level auth or inherit from parent
+ const auth = i.request.auth ?? parentAuth;
+ processAuth(auth, brunoRequestItem.request);
+
+ each(get(i, 'request.url.query'), (param) => {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.key,
+ value: param.value,
+ description: param.description,
+ type: 'query',
+ enabled: !param.disabled
+ });
+ });
+
+ each(get(i, 'request.url.variable', []), (param) => {
+ if (!param.key) {
+ // If no key, skip this iteration and discard the param
+ return;
+ }
+
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: param.key,
+ value: param.value ?? '',
+ description: param.description ?? '',
+ type: 'path',
+ enabled: true
+ });
+ });
+
+ requestMap[requestName] = brunoRequestItem;
+ }
+ });
+};
+
+
+const searchLanguageByHeader = (headers) => {
+ let contentType;
+ each(headers, (header) => {
+ if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
+ if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
+ contentType = 'json';
+ } else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
+ contentType = 'xml';
+ }
+ return false;
+ }
+ });
+ return contentType;
+};
+
+const importPostmanV2Collection = async (collection, { useWorkers = false }) => {
+ const brunoCollection = {
+ name: collection.info.name || 'Untitled Collection',
+ uid: uuid(),
+ version: '1',
+ items: [],
+ environments: [],
+ root: {
+ docs: collection.info.description || '',
+ meta: {
+ name: collection.info.name || 'Untitled Collection'
+ },
+ request: {
+ auth: {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ },
+ headers: [],
+ script: {},
+ tests: '',
+ vars: {}
+ }
+ }
+ };
+
+ if (collection.event) {
+ importScriptsFromEvents(collection.event, brunoCollection.root.request);
+ }
+
+ if (collection?.variable) {
+ importCollectionLevelVariables(collection.variable, brunoCollection.root.request);
+ }
+
+ // Collection level auth
+ processAuth(collection.auth, brunoCollection.root.request);
+
+ // Create a single scriptMap for all items
+ const scriptMap = useWorkers ? new Map() : null;
+
+ importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, { useWorkers }, scriptMap);
+
+ // Process all scripts in a single call at the top level
+ if (useWorkers && scriptMap && scriptMap.size > 0) {
+ try {
+ const { default: scriptTranslationWorker } = await import('../workers/postman-translator-worker');
+ const translatedScripts = await scriptTranslationWorker(scriptMap);
+
+ // Apply translated scripts to all items in the collection
+ const applyScriptsToItems = (items) => {
+ items.forEach(item => {
+ if (item.type === 'folder') {
+ // Apply scripts to the folder
+ if (translatedScripts.has(item.uid)) {
+ if (!item.root.request.script) {
+ item.root.request.script = {};
+ }
+
+ const script = translatedScripts.get(item.uid).request?.script?.req;
+ const tests = translatedScripts.get(item.uid).request?.script?.res;
+
+ item.root.request.script.req = script && script.length > 0 ? script : '';
+ item.root.request.script.res = tests && tests.length > 0 ? tests : '';
+ }
+
+ // Recursively apply to nested items
+ if (item.items && item.items.length > 0) {
+ applyScriptsToItems(item.items);
+ }
+ } else {
+ if (translatedScripts.has(item.uid)) {
+ if (!item.request.script) {
+ item.request.script = {};
+ }
+
+ const script = translatedScripts.get(item.uid).request?.script?.req;
+ const tests = translatedScripts.get(item.uid).request?.script?.res;
+
+ item.request.script.req = script && script.length > 0 ? script : '';
+ item.request.script.res = tests && tests.length > 0 ? tests : '';
+ }
+ }
+ });
+ };
+
+ applyScriptsToItems(brunoCollection.items);
+
+ } catch (error) {
+ console.error('Error in script translation worker:', error);
+ } finally {
+ scriptMap.clear();
+ }
+ }
+
+ return brunoCollection;
+};
+
+
+const parsePostmanCollection = async (collection, { useWorkers = false }) => {
+ try {
+ let schema = get(collection, 'info.schema');
+
+ let v2Schemas = [
+ 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
+ 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
+ 'https://schema.postman.com/json/collection/v2.0.0/collection.json',
+ 'https://schema.postman.com/json/collection/v2.1.0/collection.json'
+ ];
+
+ if (v2Schemas.includes(schema)) {
+ return await importPostmanV2Collection(collection, { useWorkers });
+ }
+
+ throw new Error('Unsupported Postman schema version. Only Postman Collection v2.0 and v2.1 are supported.');
+ } catch (err) {
+ console.log(err);
+ if (err instanceof Error) {
+ throw err;
+ }
+
+ throw new Error('Invalid Postman collection format. Please check your JSON file.');
+ }
+};
+
+const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => {
+ try {
+
+ const parsedPostmanCollection = await parsePostmanCollection(postmanCollection, { useWorkers });
+ const transformedCollection = transformItemsInCollection(parsedPostmanCollection);
+ const hydratedCollection = hydrateSeqInCollection(transformedCollection);
+ const validatedCollection = validateSchema(hydratedCollection);
+ return validatedCollection;
+ } catch (err) {
+ console.log(err);
+ throw new Error(`Import collection failed: ${err.message}`);
+ }
+};
+
+
+export default postmanToBruno;
\ No newline at end of file
diff --git a/packages/bruno-converters/src/postman/postman-translations.js b/packages/bruno-converters/src/postman/postman-translations.js
new file mode 100644
index 000000000..252c4c2d3
--- /dev/null
+++ b/packages/bruno-converters/src/postman/postman-translations.js
@@ -0,0 +1,95 @@
+import translateCode from '../utils/jscode-shift-translator';
+
+const replacements = {
+ 'pm\\.environment\\.get\\(': 'bru.getEnvVar(',
+ 'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
+ 'pm\\.variables\\.get\\(': 'bru.getVar(',
+ 'pm\\.variables\\.set\\(': 'bru.setVar(',
+ 'pm\\.variables\\.replaceIn\\(': 'bru.interpolate(',
+ 'pm\\.collectionVariables\\.get\\(': 'bru.getVar(',
+ 'pm\\.collectionVariables\\.set\\(': 'bru.setVar(',
+ 'pm\\.collectionVariables\\.has\\(': 'bru.hasVar(',
+ 'pm\\.collectionVariables\\.unset\\(': 'bru.deleteVar(',
+ 'pm\\.setNextRequest\\(': 'bru.setNextRequest(',
+ 'pm\\.test\\(': 'test(',
+ 'pm.response.to.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
+ 'pm\\.response\\.to\\.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
+ 'pm\\.response\\.json\\(': 'res.getBody(',
+ 'pm\\.expect\\(': 'expect(',
+ 'pm\\.environment\\.has\\(([^)]+)\\)': 'bru.getEnvVar($1) !== undefined && bru.getEnvVar($1) !== null',
+ 'pm\\.response\\.code': 'res.getStatus()',
+ 'pm\\.response\\.text\\(\\)': 'JSON.stringify(res.getBody())',
+ 'pm\\.expect\\.fail\\(': 'expect.fail(',
+ 'pm\\.response\\.responseTime': 'res.getResponseTime()',
+ 'pm\\.globals\\.set\\(': 'bru.setGlobalEnvVar(',
+ 'pm\\.globals\\.get\\(': 'bru.getGlobalEnvVar(',
+ 'pm\\.response\\.headers\\.get\\(': 'res.getHeader(',
+ 'pm\\.response\\.to\\.have\\.body\\(': 'expect(res.getBody()).to.equal(',
+ 'pm\\.response\\.to\\.have\\.header\\(': 'expect(res.getHeaders()).to.have.property(',
+ 'pm\\.environment\\.name': 'bru.getEnvName()',
+ 'pm\\.response\\.status': 'res.statusText',
+ 'pm\\.response\\.headers': 'res.getHeaders()',
+ "tests\\['([^']+)'\\]\\s*=\\s*([^;]+);": 'test("$1", function() { expect(Boolean($2)).to.be.true; });',
+ 'pm\\.request\\.url': 'req.getUrl()',
+ 'pm\\.request\\.method': 'req.getMethod()',
+ 'pm\\.request\\.headers': 'req.getHeaders()',
+ 'pm\\.request\\.body': 'req.getBody()',
+ 'pm\\.info\\.requestName': 'req.getName()',
+ // deprecated translations
+ 'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
+ 'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',
+ 'postman\\.clearEnvironmentVariable\\(': 'bru.deleteEnvVar(',
+ 'pm\\.execution\\.skipRequest\\(\\)': 'bru.runner.skipRequest()',
+ 'pm\\.execution\\.skipRequest': 'bru.runner.skipRequest',
+ 'pm\\.execution\\.setNextRequest\\(null\\)': 'bru.runner.stopExecution()',
+ 'pm\\.execution\\.setNextRequest\\(\'null\'\\)': 'bru.runner.stopExecution()',
+};
+
+const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {
+ const newKey = key.replace(/^pm\\\./, 'postman\\.');
+ acc[key] = replacements[key];
+ acc[newKey] = replacements[key];
+ return acc;
+}, {});
+
+const compiledReplacements = Object.entries(extendedReplacements).map(([pattern, replacement]) => ({
+ regex: new RegExp(pattern, 'g'),
+ replacement
+}));
+
+const processRegexReplacement = (code) => {
+ for (const { regex, replacement } of compiledReplacements) {
+ if (regex.test(code)) {
+ code = code.replace(regex, replacement);
+
+ }
+ }
+ if ((code.includes('pm.') || code.includes('postman.'))) {
+ code = code.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
+ }
+ return code;
+}
+
+
+const postmanTranslation = (script, options = {}) => {
+ let modifiedScript = Array.isArray(script) ? script.join('\n') : script;
+
+ try {
+ let translatedCode = translateCode(modifiedScript);
+ if ((translatedCode.includes('pm.') || translatedCode.includes('postman.'))) {
+ translatedCode = translatedCode.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
+ }
+ return translatedCode;
+ } catch (e) {
+ console.warn('Error in postman translation:', e);
+
+ try {
+ return processRegexReplacement(modifiedScript);
+ } catch (e) {
+ console.warn('Error in postman translation:', e);
+ return modifiedScript;
+ }
+ }
+};
+
+export default postmanTranslation;
\ No newline at end of file
diff --git a/packages/bruno-converters/src/utils/jscode-shift-translator.js b/packages/bruno-converters/src/utils/jscode-shift-translator.js
new file mode 100644
index 000000000..92ccf97ba
--- /dev/null
+++ b/packages/bruno-converters/src/utils/jscode-shift-translator.js
@@ -0,0 +1,710 @@
+const j = require('jscodeshift');
+const cloneDeep = require('lodash/cloneDeep');
+
+/**
+ * Efficiently builds a string representation of a member expression without using toSource()
+ *
+ * @param {Object} node - The member expression node from the AST
+ * @return {string} - String representation of the member expression (e.g., "pm.environment.get")
+ */
+function getMemberExpressionString(node) {
+ // Handle base case: if this is an Identifier
+ if (node.type === 'Identifier') {
+ return node.name;
+ }
+
+ // Handle member expressions
+ if (node.type === 'MemberExpression') {
+ const objectStr = getMemberExpressionString(node.object);
+
+ // For computed properties like obj[prop], we need special handling
+ if (node.computed) {
+ // For literals like obj["prop"], we can include them in the string
+ if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
+ return `${objectStr}.${node.property.value}`;
+ }
+ // For other computed properties, we can't reliably represent them as a simple string
+ return `${objectStr}.[computed]`;
+ }
+
+ // For regular property access like obj.prop
+ if (node.property.type === 'Identifier') {
+ return `${objectStr}.${node.property.name}`;
+ }
+ }
+
+ return '[unsupported]';
+}
+
+// Simple 1:1 translations for straightforward replacements
+const simpleTranslations = {
+ // Global Variables
+ 'pm.globals.get': 'bru.getGlobalEnvVar',
+ 'pm.globals.set': 'bru.setGlobalEnvVar',
+
+ // Environment variables
+ 'pm.environment.get': 'bru.getEnvVar',
+ 'pm.environment.set': 'bru.setEnvVar',
+ 'pm.environment.name': 'bru.getEnvName()',
+ 'pm.environment.unset': 'bru.deleteEnvVar',
+
+ // Variables
+ 'pm.variables.get': 'bru.getVar',
+ 'pm.variables.set': 'bru.setVar',
+ 'pm.variables.has': 'bru.hasVar',
+ 'pm.variables.replaceIn': 'bru.interpolate',
+ // Collection variables
+ 'pm.collectionVariables.get': 'bru.getVar',
+ 'pm.collectionVariables.set': 'bru.setVar',
+ 'pm.collectionVariables.has': 'bru.hasVar',
+ 'pm.collectionVariables.unset': 'bru.deleteVar',
+
+ // Request flow control
+ 'pm.setNextRequest': 'bru.setNextRequest',
+
+ // Testing
+ 'pm.test': 'test',
+ 'pm.expect': 'expect',
+ 'pm.expect.fail': 'expect.fail',
+
+ // Info
+ 'pm.info.requestName': 'req.getName()',
+
+ // Request properties
+ 'pm.request.url': 'req.getUrl()',
+ 'pm.request.method': 'req.getMethod()',
+ 'pm.request.headers': 'req.getHeaders()',
+ 'pm.request.body': 'req.getBody()',
+
+ // Response properties
+ 'pm.response.json': 'res.getBody',
+ 'pm.response.code': 'res.getStatus()',
+ 'pm.response.status': 'res.statusText',
+ 'pm.response.responseTime': 'res.getResponseTime()',
+ 'pm.response.statusText': 'res.statusText',
+ 'pm.response.headers': 'res.getHeaders()',
+
+ // Execution control
+ 'pm.execution.skipRequest': 'bru.runner.skipRequest',
+
+ // Legacy Postman API (deprecated) (we can use pm instead of postman, as we are converting all postman references to pm in the code as the part of pre-processing)
+ 'pm.setEnvironmentVariable': 'bru.setEnvVar',
+ 'pm.getEnvironmentVariable': 'bru.getEnvVar',
+ 'pm.clearEnvironmentVariable': 'bru.deleteEnvVar',
+};
+
+/* Complex transformations that need custom handling
+* Note: Transform functions can return either a single node or an array of nodes.
+* When returning an array of nodes, each node in the array will be inserted
+* as a separate statement, which allows a single Postman expression to be
+* transformed into multiple Bruno statements (e.g. for complex assertions).
+*/
+const complexTransformations = [
+ // pm.environment.has requires special handling
+ {
+ pattern: 'pm.environment.has',
+ transform: (path, j) => {
+ const callExpr = path.parent.value;
+
+ const args = callExpr.arguments;
+
+ // Create: bru.getEnvVar(arg) !== undefined && bru.getEnvVar(arg) !== null
+ return j.logicalExpression(
+ '&&',
+ j.binaryExpression(
+ '!==',
+ j.callExpression(j.identifier('bru.getEnvVar'), args),
+ j.identifier('undefined')
+ ),
+ j.binaryExpression(
+ '!==',
+ j.callExpression(j.identifier('bru.getEnvVar'), args),
+ j.identifier('null')
+ )
+ );
+ }
+ },
+
+ {
+ pattern: 'pm.response.text',
+ transform: (_, j) => {
+ return j.callExpression(j.identifier('JSON.stringify'), [j.identifier('res.getBody()')]);
+ }
+ },
+ {
+ pattern: 'pm.response.headers.get',
+ transform: (path, j) => {
+ return j.callExpression(j.identifier('res.getHeader'), path.parent.value.arguments);
+ }
+ },
+ // Handle pm.response.to.have.status
+ {
+ pattern: 'pm.response.to.have.status',
+ transform: (path, j) => {
+ const callExpr = path.parent.value;
+
+ const args = callExpr.arguments;
+
+ // Create: expect(res.getStatus()).to.equal(arg)
+ return j.callExpression(
+ j.memberExpression(
+ j.callExpression(
+ j.identifier('expect'),
+ [
+ j.callExpression(
+ j.identifier('res.getStatus'),
+ []
+ )
+ ]
+ ),
+ j.identifier('to.equal')
+ ),
+ args
+ );
+ }
+ },
+
+ // handle 'pm.response.to.have.header' to expect(res.getHeaders()).to.have.property(args)
+ {
+ pattern: 'pm.response.to.have.header',
+ transform: (path, j) => {
+ const callExpr = path.parent.value;
+
+ const args = callExpr.arguments;
+
+
+ if (args.length > 0) {
+ // Apply toLowerCase() to the first argument
+ args[0] = j.callExpression(
+ j.memberExpression(
+ args[0],
+ j.identifier('toLowerCase')
+ ),
+ []
+ );
+ }
+
+ // Create: expect(res.getHeaders()).to.have.property(args)
+ return j.callExpression(
+ j.memberExpression(
+ j.callExpression(
+ j.identifier('expect'),
+ [
+ j.callExpression(
+ j.identifier('res.getHeaders'),
+ []
+ )
+ ]
+ ),
+ j.identifier('to.have.property')
+ ),
+ args
+ );
+
+ }
+ },
+ // handle pm.response.to.have.body to expect(res.getBody()).to.equal(arg)
+ {
+ pattern: 'pm.response.to.have.body',
+ transform: (path, j) => {
+ const callExpr = path.parent.value;
+
+ const args = callExpr.arguments;
+
+ return j.callExpression(
+ j.memberExpression(
+ j.callExpression(j.identifier('expect'), [j.identifier('res.getBody()')]),
+ j.identifier('to.equal')
+ ),
+ args
+ );
+
+ }
+ },
+
+ // Handle pm.execution.setNextRequest(null)
+ {
+ pattern: 'pm.execution.setNextRequest',
+ transform: (path, j) => {
+ const callExpr = path.parent.value;
+
+ const args = callExpr.arguments;
+
+ // If argument is null or 'null', transform to bru.runner.stopExecution()
+ if (
+ args[0].type === 'Literal' && (args[0].value === null || args[0].value === 'null')
+ ) {
+ return j.callExpression(
+ j.identifier('bru.runner.stopExecution'),
+ []
+ );
+ }
+
+ // Otherwise, keep as bru.runner.setNextRequest with the same argument
+ return j.callExpression(
+ j.identifier('bru.runner.setNextRequest'),
+ args
+ );
+ }
+ },
+];
+
+// Create a map for complex transformations to enable O(1) lookups
+const complexTransformationsMap = {};
+complexTransformations.forEach(transform => {
+ complexTransformationsMap[transform.pattern] = transform;
+});
+
+const varInitsToReplace = new Set(['pm', 'postman', 'pm.request','pm.response', 'pm.test', 'pm.expect', 'pm.environment', 'pm.variables', 'pm.collectionVariables', 'pm.execution', 'pm.globals']);
+
+/**
+ * Process all transformations (both simple and complex) in the AST in a single pass
+ * @param {Object} ast - jscodeshift AST
+ * @param {Set} transformedNodes - Set of already transformed nodes
+ */
+function processTransformations(ast, transformedNodes) {
+ ast.find(j.MemberExpression).forEach(path => {
+ if (transformedNodes.has(path.node)) return;
+
+ // Get string representation using our utility function
+ const memberExprStr = getMemberExpressionString(path.value);
+
+ // First check for simple transformations (O(1))
+ if (simpleTranslations.hasOwnProperty(memberExprStr)) {
+ const replacement = simpleTranslations[memberExprStr];
+ j(path).replaceWith(j.identifier(replacement));
+ transformedNodes.add(path.node);
+ return; // Skip complex transformation check if simple transformation applied
+ }
+
+ // Then check for complex transformations (O(1))
+ if (complexTransformationsMap.hasOwnProperty(memberExprStr) &&
+ path.parent.value.type === 'CallExpression') {
+ const transform = complexTransformationsMap[memberExprStr];
+ const replacement = transform.transform(path, j);
+ if (Array.isArray(replacement)) {
+ replacement.forEach((nodePath, index) => {
+ if(index === 0) {
+ j(path.parent).replaceWith(nodePath);
+ } else {
+ j(path.parent.parent).insertAfter(nodePath);
+ }
+ transformedNodes.add(nodePath.node);
+ transformedNodes.add(path.parent.node);
+ });
+ } else {
+ j(path.parent).replaceWith(replacement);
+ transformedNodes.add(path.node);
+ transformedNodes.add(path.parent.node);
+ }
+ }
+ });
+}
+
+/**
+ * Translates Postman script code to Bruno script code
+ * @param {string} code - The Postman script code to translate
+ * @returns {string} The translated Bruno script code
+ */
+function translateCode(code) {
+ // Replace 'postman' with 'pm' using regex before creating the AST
+ // This is more efficient than an AST traversal
+ code = code.replace(/\bpostman\b/g, 'pm');
+
+ const ast = j(code);
+
+ // Keep track of transformed nodes to avoid double-processing
+ const transformedNodes = new Set();
+
+ // Preprocess the code to resolve all aliases
+ preprocessAliases(ast);
+
+ // Process all transformations in a single pass
+ processTransformations(ast, transformedNodes);
+
+ // Handle special Postman syntax patterns
+ handleTestsBracketNotation(ast);
+
+ return ast.toSource();
+}
+
+/**
+ * Preprocess all variable aliases in the AST to simplify later transformations
+ * @param {Object} ast - jscodeshift AST
+ */
+function preprocessAliases(ast) {
+ // Create a symbol table to track what each variable references
+ const symbolTable = new Map();
+ const MAX_ITERATIONS = 5;
+ let iterations = 0;
+
+ // Keep preprocessing until no more changes can be made
+ let changesMade;
+ do {
+ changesMade = false;
+
+ // First pass: Identify all variables
+ findVariableDefinitions(ast, symbolTable);
+
+ // Second pass: Replace all variable references with their resolved values
+ changesMade = resolveVariableReferences(ast, symbolTable) || false;
+
+ // Third pass: Clean up variable declarations that are no longer needed
+ changesMade = removeResolvedDeclarations(ast, symbolTable) || false;
+
+ iterations++;
+
+ } while (changesMade && iterations < MAX_ITERATIONS);
+}
+
+/**
+ * Find all variable definitions and track what they reference
+ * @param {Object} ast - jscodeshift AST
+ * @param {Map} symbolTable - Map to track variable references
+ */
+function findVariableDefinitions(ast, symbolTable) {
+ // Use a single traversal to handle both direct assignments and object destructuring
+ ast.find(j.VariableDeclarator).forEach(path => {
+ // Only process nodes that have an initializer
+ if (!path.value.init) return;
+
+ // Handle direct assignments: const response = pm.response
+ if (path.value.id.type === 'Identifier') {
+ const varName = path.value.id.name;
+
+ // If it's a direct identifier, just map it
+ if (path.value.init.type === 'Identifier') {
+ symbolTable.set(varName, {
+ type: 'identifier',
+ value: path.value.init.name
+ });
+ }
+ // If it's a member expression, store both parts
+ else if (path.value.init.type === 'MemberExpression') {
+ const sourceCode = getMemberExpressionString(path.value.init);
+ symbolTable.set(varName, {
+ type: 'memberExpression',
+ value: sourceCode,
+ node: path.value.init
+ });
+ }
+ }
+ // Handle object destructuring: const { response } = pm
+ else if (path.value.id.type === 'ObjectPattern' && path.value.init.type === 'Identifier') {
+ const source = path.value.init.name;
+
+ path.value.id.properties.forEach(prop => {
+ if (prop.key.name && prop.value.type === 'Identifier') {
+ const destVarName = prop.value.name;
+ symbolTable.set(destVarName, {
+ type: 'memberExpression',
+ value: `${source}.${prop.key.name}`,
+ node: j.memberExpression(
+ j.identifier(source),
+ j.identifier(prop.key.name)
+ )
+ });
+ }
+ });
+ }
+ });
+}
+
+/**
+ * Resolve variable references by replacing them with their original values
+ * @param {Object} ast - jscodeshift AST
+ * @param {Map} symbolTable - Map of variable references
+ * @returns {boolean} Whether any changes were made
+ */
+function resolveVariableReferences(ast, symbolTable) {
+ let changesMade = false;
+
+ /**
+ * Example of what this function does:
+ *
+ * Input Postman code:
+ * const response = pm.response;
+ * const jsonData = response.json(); // response is a reference to pm.response
+ *
+ * After resolution:
+ * const response = pm.response;
+ * const jsonData = pm.response.json(); // response reference is replaced with pm.response
+ *
+ * Then in the next preprocessing phase, unnecessary variables like 'response' will be removed.
+ */
+
+ // Replace all identifier references with their resolved values
+ ast.find(j.Identifier).forEach(path => {
+ const varName = path.value.name;
+
+ /**
+ * Skip specific types of identifiers that shouldn't be replaced:
+ *
+ * Case 1: Variable definitions (left side of declarations)
+ * -----------------------------------------------------
+ * In code like:
+ * const response = pm.response;
+ * ^
+ * We shouldn't replace 'response' on the left side with pm.response,
+ * which would result in: const pm.response = pm.response; (invalid syntax)
+ *
+ * Case 2: Property names in member expressions
+ * -----------------------------------------------------
+ * In code like:
+ * console.log(response.status);
+ * ^
+ * We shouldn't replace the 'status' property name with anything,
+ * only the 'response' object reference should be replaced.
+ *
+ * We only want to replace identifiers that are being used as references,
+ * not the ones being defined or used as property names.
+ */
+
+ // Skip if this is a variable definition or property name
+ if (path.parent.value.type === 'VariableDeclarator' && path.parent.value.id === path.value) {
+ return;
+ }
+ if (path.parent.value.type === 'MemberExpression' && path.parent.value.property === path.value && !path.parent.value.computed) {
+ return;
+ }
+
+ // Only replace if this is a known variable
+ if (!symbolTable.has(varName)) return;
+
+ const symbolInfo = symbolTable.get(varName);
+ if(!varInitsToReplace.has(symbolInfo.value)) {
+ return;
+ }
+ const newNode = cloneDeep(symbolInfo.node);
+ j(path).replaceWith(newNode);
+ symbolTable.set(varName, {
+ type: 'memberExpression',
+ value: symbolInfo.value,
+ node: newNode
+ });
+ changesMade = true;
+
+ });
+
+ return changesMade;
+}
+
+/**
+ * Remove variable declarations that have been resolved
+ * @param {Object} ast - jscodeshift AST
+ * @param {Map} symbolTable - Map of variable references
+ * @returns {boolean} Whether any changes were made
+ */
+function removeResolvedDeclarations(ast, symbolTable) {
+ let changesMade = false;
+
+ /**
+ * Example of what this function does:
+ *
+ * Original Postman code:
+ * const response = pm.response;
+ * const jsonData = response.json();
+ * console.log(jsonData.name);
+ *
+ * After variable resolution:
+ * const response = pm.response; // This declaration is now redundant
+ * const jsonData = pm.response.json(); // This value has been resolved
+ * console.log(jsonData.name); // This still references jsonData
+ *
+ * Final code after this cleanup step:
+ * const jsonData = pm.response.json(); // response variable declaration is removed
+ * console.log(jsonData.name); // jsonData is kept since it's still referenced
+ *
+ * We only remove declarations that:
+ * 1. Have been fully resolved (references to pm.* objects)
+ * 2. No longer provide any value (since all references were replaced with resolved values)
+ */
+
+ // Use a single traversal to handle both regular variable declarations and destructuring
+ ast.find(j.VariableDeclarator).forEach(path => {
+ // Case 1: Handle regular variable declarations
+ if (path.value.id.type === 'Identifier') {
+ const varName = path.value.id.name;
+ const replacement = symbolTable.get(varName);
+ if(!replacement || !varInitsToReplace.has(replacement.value)) return;
+
+ /**
+ * This code differentiates between two types of variable declarations:
+ *
+ * Example 1: Single variable declaration
+ * -----------------------------------
+ * Input: const response = pm.response;
+ * Action: The entire statement can be removed
+ * Output: [statement removed]
+ *
+ * Example 2: Multiple variables in one declaration
+ * -----------------------------------
+ * Input: const response = pm.response, unrelated = 5;
+ * Action: Only remove the 'response' declarator, keep the others
+ * Output: const unrelated = 5;
+ *
+ * We need this distinction to ensure we don't accidentally remove
+ * unrelated variables that happen to be declared in the same statement.
+ */
+ const declarationPath = j(path).closest(j.VariableDeclaration);
+ if (declarationPath.get().value.declarations.length === 1) {
+ declarationPath.remove();
+ } else {
+ // Otherwise just remove this declarator
+ j(path).remove();
+ }
+
+ changesMade = true;
+ }
+ // Case 2: Handle destructuring of pm
+ else if (path.value.id.type === 'ObjectPattern' &&
+ path.value.init &&
+ path.value.init.type === 'Identifier' &&
+ path.value.init.name === 'pm') {
+
+ /**
+ * Example of destructuring removal:
+ *
+ * Original Postman code:
+ * const { response, environment } = pm;
+ * console.log(response.json().name);
+ * console.log(environment.get("variable"));
+ *
+ * After variable resolution steps:
+ * const { response, environment } = pm; // This destructuring is now redundant
+ * console.log(pm.response.json().name); // 'response' references already replaced with pm.response
+ * console.log(pm.environment.get("variable")); // 'environment' references replaced
+ *
+ * Final code after this cleanup step:
+ * console.log(pm.response.json().name); // Destructuring declaration is completely removed
+ * console.log(pm.environment.get("variable"));
+ *
+ * This step specifically targets the Postman pattern of destructuring the pm object,
+ * which is common in Postman scripts but needs to be removed in the Bruno conversion.
+ */
+
+ const declarationPath = j(path).closest(j.VariableDeclaration);
+ if (declarationPath.get().value.declarations.length === 1) {
+ declarationPath.remove();
+ } else {
+ j(path).remove();
+ }
+
+ changesMade = true;
+ }
+ });
+
+ return changesMade;
+}
+
+/**
+ * Handle Postman's tests["..."] = ... syntax
+ * @param {Object} ast - jscodeshift AST
+ */
+function handleTestsBracketNotation(ast) {
+ // Find the ExpressionStatement that contains the assignment
+ ast.find(j.ExpressionStatement, {
+ expression: {
+ type: 'AssignmentExpression',
+ left: {
+ type: 'MemberExpression',
+ object: { name: 'tests' },
+ computed: true,
+ property: {} // Accept any property type
+ }
+ }
+ }).forEach(path => {
+ // Get the assignment expression
+ const assignment = path.value.expression;
+ const left = assignment.left;
+
+ // Verify it's a valid tests[] expression
+ if (left.object.type === 'Identifier' &&
+ left.object.name === 'tests' &&
+ left.computed === true) {
+
+ const property = left.property;
+ const rightSide = assignment.right;
+
+ // Handle string literals
+ if (property.type === 'Literal' && typeof property.value === 'string') {
+ const testName = property.value;
+
+ // Replace with test() function call
+ j(path).replaceWith(
+ j.expressionStatement(
+ j.callExpression(
+ j.identifier('test'),
+ [
+ j.literal(testName),
+ j.functionExpression(
+ null,
+ [],
+ j.blockStatement([
+ j.expressionStatement(
+ j.memberExpression(
+ j.callExpression(
+ j.identifier('expect'),
+ [
+ j.callExpression(
+ j.identifier('Boolean'),
+ [rightSide]
+ )
+ ]
+ ),
+ j.identifier('to.be.true')
+ )
+ )
+ ])
+ )
+ ]
+ )
+ )
+ );
+ }
+ // Handle template literals
+ else if (property.type === 'TemplateLiteral') {
+ // Create a template literal with the same quasi and expressions
+ const templateLiteral = j.templateLiteral(
+ property.quasis,
+ property.expressions
+ );
+
+ // Replace with test() function call using template literal
+ j(path).replaceWith(
+ j.expressionStatement(
+ j.callExpression(
+ j.identifier('test'),
+ [
+ templateLiteral,
+ j.functionExpression(
+ null,
+ [],
+ j.blockStatement([
+ j.expressionStatement(
+ j.memberExpression(
+ j.callExpression(
+ j.identifier('expect'),
+ [
+ j.callExpression(
+ j.identifier('Boolean'),
+ [rightSide]
+ )
+ ]
+ ),
+ j.identifier('to.be.true')
+ )
+ )
+ ])
+ )
+ ]
+ )
+ )
+ );
+ }
+ }
+ });
+}
+
+export { getMemberExpressionString };
+export default translateCode;
\ No newline at end of file
diff --git a/packages/bruno-converters/src/workers/postman-translator-worker.js b/packages/bruno-converters/src/workers/postman-translator-worker.js
new file mode 100644
index 000000000..1c9ebea79
--- /dev/null
+++ b/packages/bruno-converters/src/workers/postman-translator-worker.js
@@ -0,0 +1,211 @@
+const { Worker } = require('node:worker_threads');
+const path = require('node:path');
+const os = require('node:os');
+
+function getMaxWorkers() {
+ return Math.max(os.availableParallelism(), 1)
+}
+
+class WorkerPool {
+ constructor(scriptPath, size) {
+ this.workers = [];
+ this.idle = [];
+ this.queue = [];
+ this.scriptPath = scriptPath;
+ this.size = size;
+ }
+
+ // Initialize the worker pool
+ initialize() {
+ for (let i = 0; i < this.size; i++) {
+ const worker = new Worker(this.scriptPath);
+ this.workers.push(worker);
+ this.idle.push(i);
+ }
+ }
+
+ // Run a task on a worker
+ runTask(data) {
+ return new Promise((resolve, reject) => {
+ const task = { data, resolve, reject };
+
+ if (this.idle.length > 0) {
+ this._runTaskOnWorker(this.idle.shift(), task);
+ } else {
+ this.queue.push(task);
+ }
+ });
+ }
+
+ // Run a task on a specific worker
+ _runTaskOnWorker(workerId, task) {
+ const worker = this.workers[workerId];
+
+ const messageHandler = (result) => {
+ // Cleanup listeners
+ worker.removeListener('message', messageHandler);
+ worker.removeListener('error', errorHandler);
+
+ // Mark worker as idle
+ this.idle.push(workerId);
+
+ // Process queue if tasks are waiting
+ if (this.queue.length > 0) {
+ this._runTaskOnWorker(workerId, this.queue.shift());
+ }
+
+ // Resolve the task
+ task.resolve(result);
+ };
+
+ const errorHandler = (err) => {
+ worker.removeListener('message', messageHandler);
+ worker.removeListener('error', errorHandler);
+
+ this.idle.push(workerId);
+
+ if (this.queue.length > 0) {
+ this._runTaskOnWorker(workerId, this.queue.shift());
+ }
+
+ task.reject(err);
+ };
+
+ worker.on('message', messageHandler);
+ worker.on('error', errorHandler);
+ worker.postMessage(task.data);
+ }
+
+ // Terminate all workers
+ terminate() {
+ for (const worker of this.workers) {
+ worker.terminate();
+ }
+ this.workers = [];
+ this.idle = [];
+ }
+}
+
+// Helper function to count lines in a script
+function countScriptLines(script) {
+ if (!script) return 0;
+ return Array.isArray(script) ? script.length : script.split('\n').length;
+}
+
+// Calculate complexity of a script entry
+function calculateScriptComplexity([uid, entry]) {
+ let totalLines = 0;
+ const { events } = entry
+
+ if (events && Array.isArray(events)) {
+ events.forEach(({ script }) => {
+ if (script && script.exec) {
+ totalLines += countScriptLines(script.exec);
+ }
+ });
+ }
+
+ return { uid, entry, complexity: totalLines || 1 }; // Minimum complexity of 1
+}
+
+// Create balanced batches based on script complexity
+function createBalancedBatches(scriptEntries, workerCount) {
+ // Calculate complexity for each script
+ const scriptsWithComplexity = scriptEntries.map(calculateScriptComplexity);
+
+ // Sort scripts by complexity (descending)
+ scriptsWithComplexity.sort((a, b) => b.complexity - a.complexity);
+
+ // Initialize batches
+ const batches = Array.from({ length: workerCount }, () => ({
+ entries: [],
+ totalComplexity: 0
+ }));
+
+ // Algorithm: Greedy load balancing
+ // 1. Process scripts in descending order of complexity
+ // 2. Always assign each script to the batch with lowest current load
+ // 3. This minimizes the maximum workload across all workers
+ for (const { uid, entry, complexity } of scriptsWithComplexity) {
+
+ const batchWithLowestComplexity = batches.reduce(
+ (target, current) => current.totalComplexity < target.totalComplexity ? current : target
+ );
+
+ // Add the script to this batch
+ batchWithLowestComplexity.entries.push({uid, entry});
+ batchWithLowestComplexity.totalComplexity += complexity;
+ }
+
+ return batches.map(batch =>
+ batch.entries.map(({ uid, entry }) => [uid, entry])
+ ).filter(batch => batch.length > 0);
+}
+
+const scriptTranslationWorker = async (scriptMap) => {
+ // Convert the Map to an array of entries
+ const scriptEntries = Array.from(scriptMap.entries());
+ const maxWorkers = getMaxWorkers();
+
+ // For very small collections, don't parallelize
+ if (scriptEntries.length <= 50) {
+ const workerPool = new WorkerPool(path.join(__dirname,'./src/workers/scripts/translate-postman-scripts.js'), 1);
+ workerPool.initialize();
+
+ try {
+ const translatedScripts = new Map();
+ const result = await workerPool.runTask({ scripts: scriptEntries });
+
+ if (result.error) {
+ console.error('Error in script translation worker:', result.error);
+ throw new Error(result.error);
+ }
+
+ result.forEach(([uid, { request }]) => {
+ translatedScripts.set(uid, { request });
+ });
+
+ return translatedScripts;
+ } finally {
+ workerPool.terminate();
+ }
+ }
+
+
+ const workerCount = Math.min(maxWorkers, 4);
+
+ // Create balanced batches based on script complexity
+ const batches = createBalancedBatches(scriptEntries, workerCount);
+
+ const translatedScripts = new Map();
+
+ // Create worker pool with optimal size
+ const workerPool = new WorkerPool(path.join(__dirname,'./src/workers/scripts/translate-postman-scripts.js'), workerCount);
+ workerPool.initialize();
+
+ // Process all batches in parallel using worker pool
+ const batchPromises = batches.map(batch => {
+ return workerPool.runTask({ scripts: batch })
+ .then(modScripts => {
+ modScripts.forEach(([name, { request }]) => {
+ translatedScripts.set(name, { request });
+ });
+ })
+ .catch(err => {
+ console.error('Error in script translation worker:', err);
+ throw new Error(err);
+ });
+ });
+
+ // Wait for all batches to complete
+ try {
+ await Promise.allSettled(batchPromises);
+ } finally {
+ // Clean up worker pool
+ workerPool.terminate();
+ }
+
+ return translatedScripts;
+};
+
+export default scriptTranslationWorker
\ No newline at end of file
diff --git a/packages/bruno-converters/src/workers/scripts/translate-postman-scripts.js b/packages/bruno-converters/src/workers/scripts/translate-postman-scripts.js
new file mode 100644
index 000000000..816a08f03
--- /dev/null
+++ b/packages/bruno-converters/src/workers/scripts/translate-postman-scripts.js
@@ -0,0 +1,43 @@
+const { parentPort } = require('node:worker_threads');
+const { postmanTranslation } = require('@usebruno/converters');
+
+parentPort.on('message', (workerData) => {
+ try {
+ const { scripts } = workerData;
+ const modScripts = scripts.map(([uid, { events }]) => {
+ const requestObject = {
+ script: {}
+ }
+
+ if (events && Array.isArray(events)) {
+ events.forEach((event) => {
+ if(event?.script && event.script.exec) {
+ if(event.listen === 'prerequest') {
+ if(event.script.exec && event.script.exec.length > 0) {
+ requestObject.script.req = postmanTranslation(event.script.exec);
+ } else {
+ requestObject.script.req = '';
+ }
+ }
+
+ if(event.listen === 'test') {
+ if(event.script.exec && event.script.exec.length > 0) {
+ requestObject.script.res = postmanTranslation(event.script.exec);
+ } else {
+ requestObject.script.res = '';
+ }
+ }
+ }
+ });
+ }
+
+ return [uid, { request: requestObject }];
+ });
+
+ parentPort.postMessage(modScripts);
+ }
+ catch(error) {
+ console.error(error);
+ parentPort.postMessage({ error: error?.message });
+ }
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js
new file mode 100644
index 000000000..c09065fa6
--- /dev/null
+++ b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js
@@ -0,0 +1,159 @@
+import { describe, it, expect } from '@jest/globals';
+import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
+
+describe('insomnia-collection', () => {
+ it('should correctly import a valid Insomnia v5 collection file', async () => {
+ const brunoCollection = insomniaToBruno(insomniaCollection);
+
+ expect(brunoCollection).toMatchObject(expectedOutput)
+ });
+});
+
+const insomniaCollection = `
+type: collection.insomnia.rest/5.0
+name: Hello World Workspace Insomnia
+meta:
+ id: wrk_9381cf78cb0a4eaaab1d571f29f928dc
+ created: 1744194421962
+ modified: 1744194421962
+collection:
+ - name: Folder1
+ meta:
+ id: fld_6beacec0bd2f4370be98169217e82a2c
+ created: 1744194421968
+ modified: 1744194421968
+ sortKey: -1744194421968
+ children:
+ - url: https://httpbin.org/get
+ name: Request1
+ meta:
+ id: req_e9fbdc9c88984068a04f442e052d4ff1
+ created: 1744194421965
+ modified: 1744194421965
+ isPrivate: false
+ sortKey: -1744194421965
+ method: GET
+ settings:
+ renderRequestBody: true
+ encodeUrl: true
+ followRedirects: global
+ cookies:
+ send: true
+ store: true
+ rebuildPath: true
+ - name: Folder2
+ meta:
+ id: fld_96508d79bf06420a853b07482ab280d7
+ created: 1744194421969
+ modified: 1744194421969
+ sortKey: -1744194421969
+ children:
+ - url: https://httpbin.org/get
+ name: Request2
+ meta:
+ id: req_3c572aa26a964f1f800bfa5c53cacb75
+ created: 1744194421967
+ modified: 1744194421967
+ isPrivate: false
+ sortKey: -1744194421968
+ method: GET
+ settings:
+ renderRequestBody: true
+ encodeUrl: true
+ followRedirects: global
+ cookies:
+ send: true
+ store: true
+ rebuildPath: true
+cookieJar:
+ name: Default Jar
+ meta:
+ id: jar_9ecb97079037c7d5bb888f0bfdec9b0e1275c6d1
+ created: 1744194421971
+ modified: 1744194421971
+environments:
+ name: Imported Environment
+ meta:
+ id: env_a8a9a8ff952d4d079edf53f8ee22a423
+ created: 1744194421970
+ modified: 1744194421970
+ isPrivate: false
+ data:
+ var1: value1
+ var2: value2
+`
+
+const expectedOutput = {
+ "environments": [],
+ "items": [
+ {
+ "items": [
+ {
+ "name": "Request1",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 1,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Folder1",
+ "type": "folder",
+ "uid": "mockeduuidvalue123456",
+ },
+ {
+ "items": [
+ {
+ "name": "Request2",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 1,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Folder2",
+ "type": "folder",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Hello World Workspace Insomnia",
+ "uid": "mockeduuidvalue123456",
+ "version": "1",
+};
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js
new file mode 100644
index 000000000..e6cb5a2c8
--- /dev/null
+++ b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js
@@ -0,0 +1,190 @@
+import { describe, it, expect } from '@jest/globals';
+import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
+
+describe('insomnia-collection', () => {
+ it('should correctly import a valid Insomnia collection file', async () => {
+ const brunoCollection = insomniaToBruno(insomniaCollection);
+
+ expect(brunoCollection).toMatchObject(expectedOutput)
+ });
+});
+
+const insomniaCollection = {
+ "_type": "export",
+ "__export_format": 4,
+ "__export_date": "2024-05-20T10:02:44.123Z",
+ "__export_source": "insomnia.desktop.app:v2021.5.2",
+ "resources": [
+ {
+ "_id": "req_1",
+ "_type": "request",
+ "parentId": "fld_1",
+ "name": "Request1",
+ "method": "GET",
+ "url": "https://httpbin.org/get",
+ "parameters": []
+ },
+ {
+ "_id": "req_2",
+ "_type": "request",
+ "parentId": "fld_2",
+ "name": "Request2",
+ "method": "GET",
+ "url": "https://httpbin.org/get",
+ "parameters": []
+ },
+ {
+ "_id": "fld_1",
+ "_type": "request_group",
+ "parentId": "wrk_1",
+ "name": "Folder1"
+ },
+ {
+ "_id": "fld_2",
+ "_type": "request_group",
+ "parentId": "wrk_1",
+ "name": "Folder2"
+ },
+ {
+ "_id": "wrk_1",
+ "_type": "workspace",
+ "name": "Hello World Workspace Insomnia"
+ },
+ {
+ "_id": "env_1",
+ "_type": "environment",
+ "parentId": "wrk_1",
+ "data": {
+ "var1": "value1",
+ "var2": "value2"
+ }
+ }
+ ]
+};
+
+const expectedOutput = {
+ "environments": [],
+ "items": [
+ {
+ "items": [
+ {
+ "name": "Request1",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 1,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ {
+ "name": "Request1",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 2,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Folder1",
+ "type": "folder",
+ "uid": "mockeduuidvalue123456",
+ },
+ {
+ "items": [
+ {
+ "name": "Request2",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 1,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ {
+ "name": "Request2",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "url": "https://httpbin.org/get",
+ },
+ "seq": 2,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Folder2",
+ "type": "folder",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Hello World Workspace Insomnia",
+ "uid": "mockeduuidvalue123456",
+ "version": "1",
+};
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-circular-references.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-circular-references.spec.js
new file mode 100644
index 000000000..eedf52567
--- /dev/null
+++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-circular-references.spec.js
@@ -0,0 +1,248 @@
+import { describe, it, expect } from '@jest/globals';
+import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
+
+describe('openapi-circular-references', () => {
+ it('should handle simple circular references in schema correctly', async () => {
+ const brunoCollection = openApiToBruno(circularRefsData);
+
+ expect(brunoCollection).toMatchObject(circularRefsOutput);
+ });
+
+ it('should handle complex circular reference chains correctly', async () => {
+ const brunoCollection = openApiToBruno(complexCircularRefsData);
+
+ expect(brunoCollection).toMatchObject(circularRefsOutput);
+ });
+});
+
+const circularRefsData = {
+ "components": {
+ "schemas": {
+ "schema_1": {
+ "additionalProperties": false,
+ "description": "schema_1",
+ "properties": {
+ "conditions": {
+ "$ref": "#/components/schemas/schema_1"
+ }
+ },
+ "type": "object"
+ },
+ "schema_2": {
+ "additionalProperties": false,
+ "description": "schema_2",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_1",
+ "items": { "$ref": "#/components/schemas/schema_1" },
+ "type": "array"
+ },
+ "operation": {
+ "description": "operation",
+ "enum": ["ANY", "ALL"],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "info": {
+ "description": "circular reference openapi sample json spec",
+ "title": "circular reference openapi sample json spec",
+ "version": "0.1"
+ },
+ "openapi": "3.0.1",
+ "paths": {
+ "/": {
+ "post": {
+ "deprecated": false,
+ "description": "echo ping api",
+ "operationId": "echo ping",
+ "parameters": [],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/schema_1"
+ }
+ }
+ },
+ "description": "echo ping api",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "example": "ping"
+ }
+ },
+ "description": "Returned if the request is successful."
+ }
+ }
+ }
+ }
+ },
+ "servers": [{ "url": "https://echo.usebruno.com" }]
+};
+
+// More complex circular reference test with a longer chain
+const complexCircularRefsData = {
+ "components": {
+ "schemas": {
+ "schema_1": {
+ "additionalProperties": false,
+ "description": "schema_1",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_1",
+ "items": { "$ref": "#/components/schemas/schema_2" },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "schema_2": {
+ "additionalProperties": false,
+ "description": "schema_2",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_2",
+ "items": { "$ref": "#/components/schemas/schema_3" },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "schema_3": {
+ "additionalProperties": false,
+ "description": "schema_3",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_3",
+ "items": { "$ref": "#/components/schemas/schema_4" },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "schema_4": {
+ "additionalProperties": false,
+ "description": "schema_4",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_4",
+ "items": { "$ref": "#/components/schemas/schema_5" },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "schema_5": {
+ "additionalProperties": false,
+ "description": "schema_4",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_5",
+ "items": { "$ref": "#/components/schemas/schema_1" },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "schema_6": {
+ "additionalProperties": false,
+ "description": "schema_3",
+ "properties": {
+ "conditionGroup": {
+ "description": "nested schema_3",
+ "items": { "$ref": "#/components/schemas/schema_1" },
+ "type": "array"
+ },
+ "operation": {
+ "description": "operation",
+ "enum": ["ANY", "ALL"],
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "info": {
+ "description": "circular reference openapi sample json spec",
+ "title": "circular reference openapi sample json spec",
+ "version": "0.1"
+ },
+ "openapi": "3.0.1",
+ "paths": {
+ "/": {
+ "post": {
+ "deprecated": false,
+ "description": "echo ping api",
+ "operationId": "echo ping",
+ "parameters": [],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/schema_1"
+ }
+ }
+ },
+ "description": "echo ping api",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "example": "ping"
+ }
+ },
+ "description": "Returned if the request is successful."
+ }
+ }
+ }
+ }
+ },
+ "servers": [{ "url": "https://echo.usebruno.com" }]
+};
+
+const circularRefsOutput = {
+ "environments": [
+ {
+ "name": "Environment 1",
+ "variables": [
+ {
+ "enabled": true,
+ "name": "baseUrl",
+ "secret": false,
+ "type": "text",
+ "value": "https://echo.usebruno.com",
+ },
+ ],
+ },
+ ],
+ "items": [
+ {
+ "name": "echo ping",
+ "type": "http-request",
+ "request": {
+ "url": "{{baseUrl}}/",
+ "method": "POST",
+ "auth": {
+ "mode": "none",
+ },
+ "headers": [],
+ "params": [],
+ "body": {
+ "mode": "json",
+ }
+ },
+ },
+ ],
+ "name": "circular reference openapi sample json spec",
+ "version": "1",
+};
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js
new file mode 100644
index 000000000..7c2c60409
--- /dev/null
+++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js
@@ -0,0 +1,106 @@
+import { describe, it, expect } from '@jest/globals';
+import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
+
+describe('openapi-collection', () => {
+ it('should correctly import a valid OpenAPI file', async () => {
+ const brunoCollection = openApiToBruno(openApiCollectionString);
+
+ expect(brunoCollection).toMatchObject(expectedOutput);
+ });
+});
+
+const openApiCollectionString = `
+openapi: "3.0.0"
+info:
+ version: "1.0.0"
+ title: "Hello World OpenAPI"
+paths:
+ /get:
+ get:
+ tags:
+ - Folder1
+ - Folder2
+ summary: "Request1 and Request2"
+ operationId: "getRequests"
+ responses:
+ '200':
+ description: "Successful response"
+components:
+ parameters:
+ var1:
+ in: "query"
+ name: "var1"
+ required: true
+ schema:
+ type: "string"
+ default: "value1"
+ var2:
+ in: "query"
+ name: "var2"
+ required: true
+ schema:
+ type: "string"
+ default: "value2"
+servers:
+ - url: "https://httpbin.org"
+`;
+
+const expectedOutput = {
+ "environments": [
+ {
+ "name": "Environment 1",
+ "uid": "mockeduuidvalue123456",
+ "variables": [
+ {
+ "enabled": true,
+ "name": "baseUrl",
+ "secret": false,
+ "type": "text",
+ "uid": "mockeduuidvalue123456",
+ "value": "https://httpbin.org",
+ },
+ ],
+ },
+ ],
+ "items": [
+ {
+ "items": [
+ {
+ "name": "Request1 and Request2",
+ "request": {
+ "auth": {
+ "basic": null,
+ "bearer": null,
+ "digest": null,
+ "mode": "none",
+ },
+ "body": {
+ "formUrlEncoded": [],
+ "json": null,
+ "mode": "none",
+ "multipartForm": [],
+ "text": null,
+ "xml": null,
+ },
+ "headers": [],
+ "method": "GET",
+ "params": [],
+ "script": {
+ "res": null,
+ },
+ "url": "{{baseUrl}}/get",
+ },
+ "seq": 1,
+ "type": "http-request",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Folder1",
+ "type": "folder",
+ "uid": "mockeduuidvalue123456",
+ },
+ ],
+ "name": "Hello World OpenAPI",
+ "uid": "mockeduuidvalue123456",
+ "version": "1",
+};
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js b/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js
new file mode 100644
index 000000000..7d5018997
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/bruno-to-postman.spec.js
@@ -0,0 +1,494 @@
+import { sanitizeUrl, transformUrl, brunoToPostman } from "../../src/postman/bruno-to-postman";
+
+describe('transformUrl', () => {
+ it('should handle basic URL with path variables', () => {
+ const url = 'https://example.com/{{username}}/api/resource/:id';
+ const params = [
+ { name: 'id', value: '123', type: 'path' },
+ ];
+
+ const result = transformUrl(url, params);
+
+ expect(result).toEqual({
+ raw: 'https://example.com/{{username}}/api/resource/:id',
+ protocol: 'https',
+ host: ['example', 'com'],
+ path: ['{{username}}', 'api', 'resource', ':id'],
+ query: [],
+ variable: [
+ { key: 'id', value: '123' },
+ ]
+ });
+ });
+
+ it('should handle URL with query parameters', () => {
+ const url = 'https://example.com/api/resource?limit=10&offset=20';
+ const params = [
+ { name: 'limit', value: '10', type: 'query' },
+ { name: 'offset', value: '20', type: 'query' }
+ ];
+
+ const result = transformUrl(url, params);
+
+ expect(result).toEqual({
+ raw: 'https://example.com/api/resource?limit=10&offset=20',
+ protocol: 'https',
+ host: ['example', 'com'],
+ path: ['api', 'resource'],
+ query: [
+ { key: 'limit', value: '10' },
+ { key: 'offset', value: '20' }
+ ],
+ variable: []
+ });
+ });
+
+ it('should handle URL without protocol', () => {
+ const url = 'example.com/api/resource';
+ const params = [];
+
+ const result = transformUrl(url, params);
+
+ expect(result).toEqual({
+ raw: 'example.com/api/resource',
+ protocol: '',
+ host: ['example', 'com'],
+ path: ['api', 'resource'],
+ query: [],
+ variable: []
+ });
+ });
+});
+
+describe('sanitizeUrl', () => {
+ it('should replace backslashes with slashes', () => {
+ const input = 'http:\\\\example.com\\path\\to\\file';
+ const expected = 'http://example.com/path/to/file';
+ expect(sanitizeUrl(input)).toBe(expected);
+ });
+
+ it('should collapse multiple slashes into a single slash', () => {
+ const input = 'http://example.com//path///to////file';
+ const expected = 'http://example.com/path/to/file';
+ expect(sanitizeUrl(input)).toBe(expected);
+ });
+
+ it('should handle URLs with mixed slashes', () => {
+ const input = 'http:\\example.com//path\\to//file';
+ const expected = 'http://example.com/path/to/file';
+ expect(sanitizeUrl(input)).toBe(expected);
+ });
+});
+
+describe('brunoToPostman null checks and fallbacks', () => {
+ it('should handle null or undefined headers', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ headers: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.header).toEqual([]);
+ });
+
+ it('should handle null or undefined items in headers', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ headers: [
+ { name: null, value: 'test-value', enabled: true },
+ { name: 'Content-Type', value: null, enabled: true }
+ ]
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.header).toEqual([
+ { key: '', value: 'test-value', disabled: false, type: 'default' },
+ { key: 'Content-Type', value: '', disabled: false, type: 'default' }
+ ]);
+ });
+
+ it('should handle null or undefined body', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ body: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ // Should not have body property since we're checking for body before adding it
+ expect(result.item[0].request.body).toBeUndefined();
+ });
+
+ it('should handle null or undefined body mode', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ body: {}
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ // Should use default raw mode for undefined body mode
+ expect(result.item[0].request.body).toEqual({
+ mode: 'raw',
+ raw: ''
+ });
+ });
+
+ it('should handle null or undefined formUrlEncoded array', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ body: {
+ mode: 'formUrlEncoded',
+ formUrlEncoded: null
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.body).toEqual({
+ mode: 'urlencoded',
+ urlencoded: []
+ });
+ });
+
+ it('should handle null or undefined multipartForm array', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ body: {
+ mode: 'multipartForm',
+ multipartForm: null
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.body).toEqual({
+ mode: 'formdata',
+ formdata: []
+ });
+ });
+
+ it('should handle null or undefined items in form data', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ body: {
+ mode: 'formUrlEncoded',
+ formUrlEncoded: [
+ { name: null, value: 'test-value', enabled: true },
+ { name: 'field', value: null, enabled: true }
+ ]
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.body.urlencoded).toEqual([
+ { key: '', value: 'test-value', disabled: false, type: 'default' },
+ { key: 'field', value: '', disabled: false, type: 'default' }
+ ]);
+ });
+
+ it('should handle null or undefined method', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ url: 'https://example.com',
+ method: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.method).toBe('GET');
+ });
+
+ it('should handle null or undefined url', () => {
+ // Mock console.error to prevent it from logging during test
+ const originalConsoleError = console.error;
+ console.error = jest.fn();
+
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.url.raw).toBe('');
+ });
+
+ it('should handle null or undefined params', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ params: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.url.variable).toEqual([]);
+ });
+
+ it('should handle null or undefined docs', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ docs: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.description).toBe('');
+ });
+
+ it('should handle null or undefined folder name', () => {
+ const simpleCollection = {
+ items: [
+ {
+ type: 'folder',
+ name: null,
+ items: []
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].name).toBe('Untitled Folder');
+ });
+
+ it('should handle null or undefined request name', () => {
+ const simpleCollection = {
+ items: [
+ {
+ type: 'http-request',
+ name: null,
+ request: {
+ method: 'GET',
+ url: 'https://example.com'
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].name).toBe('Untitled Request');
+ });
+
+ it('should handle null or undefined folder items', () => {
+ const simpleCollection = {
+ items: [
+ {
+ type: 'folder',
+ name: 'Test Folder',
+ items: null
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].item).toEqual([]);
+ });
+
+ it('should handle null or undefined auth object', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ auth: null
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.auth).toEqual({ type: 'noauth' });
+ });
+
+ it('should handle missing token in bearer auth', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ auth: {
+ mode: 'bearer',
+ bearer: { token: null }
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.auth).toEqual({
+ type: 'bearer',
+ bearer: {
+ key: 'token',
+ value: '',
+ type: 'string'
+ }
+ });
+ });
+
+ it('should handle missing username/password in basic auth', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ auth: {
+ mode: 'basic',
+ basic: { username: null, password: undefined }
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.auth).toEqual({
+ type: 'basic',
+ basic: [
+ {
+ key: 'password',
+ value: '',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: '',
+ type: 'string'
+ }
+ ]
+ });
+ });
+
+ it('should handle missing key/value in apikey auth', () => {
+ const simpleCollection = {
+ items: [
+ {
+ name: 'Test Request',
+ type: 'http-request',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ auth: {
+ mode: 'apikey',
+ apikey: { key: null, value: undefined }
+ }
+ }
+ }
+ ]
+ };
+
+ const result = brunoToPostman(simpleCollection);
+ expect(result.item[0].request.auth).toEqual({
+ type: 'apikey',
+ apikey: [
+ {
+ key: 'key',
+ value: '',
+ type: 'string'
+ },
+ {
+ key: 'value',
+ value: '',
+ type: 'string'
+ }
+ ]
+ });
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js b/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js
new file mode 100644
index 000000000..6101548bd
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-env-to-bruno-env.spec.js
@@ -0,0 +1,69 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBrunoEnvironment from '../../src/postman/postman-env-to-bruno-env';
+
+describe('postmanToBrunoEnvironment Function', () => {
+ it('should correctly import a valid Postman environment file', async () => {
+ const postmanEnvironment = {
+ "id": "some-id",
+ "name": "My Environment",
+ "values": [
+ {
+ "key": "var1",
+ "value": "value1",
+ "enabled": true,
+ "type": "text"
+ },
+ {
+ "key": "var2",
+ "value": "value2",
+ "enabled": false,
+ "type": "secret"
+ }
+ ]
+ };
+
+ const brunoEnvironment = await postmanToBrunoEnvironment(postmanEnvironment);
+
+ const expectedEnvironment = {
+ name: 'My Environment',
+ variables: [
+ {
+ name: 'var1',
+ value: 'value1',
+ enabled: true,
+ secret: false,
+ uid: "mockeduuidvalue123456",
+ },
+ {
+ name: 'var2',
+ value: 'value2',
+ enabled: false,
+ secret: true,
+ uid: "mockeduuidvalue123456",
+ },
+ ],
+ };
+
+ expect(brunoEnvironment).toEqual(expectedEnvironment);
+ });
+
+ it.skip('should throw Error when JSON parsing fails', async () => {
+ const invalidBrunoEnvironment = {
+ "id": "some-id",
+ "name": "My Environment",
+ "values": [
+ {
+ "key": "var1",
+ "value": "value1",
+ "enabled": true,
+ "type": "text"
+ }
+ ]
+ }
+
+ await expect(postmanToBrunoEnvironment(invalidBrunoEnvironment)).rejects.toThrow(Error);
+ await expect(postmanToBrunoEnvironment(invalidBrunoEnvironment)).rejects.toThrow(
+ 'Unable to parse the postman environment json file'
+ );
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js
new file mode 100644
index 000000000..d1a5caa7a
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/collection-auth.spec.js
@@ -0,0 +1,238 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBruno from '../../../src/postman/postman-to-bruno';
+
+describe('Collection Authentication', () => {
+ it('should handle basic auth at collection level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Collection level basic auth',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [],
+ auth: {
+ type: 'basic',
+ basic: [
+ {
+ key: 'password',
+ value: 'testpass',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: 'testuser',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+ // console.log('result', JSON.stringify(result, null, 2));
+
+ expect(result.root.request.auth).toEqual({
+ mode: 'basic',
+ basic: {
+ username: 'testuser',
+ password: 'testpass'
+ },
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle bearer token auth at collection level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Collection level bearer token',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [],
+ auth: {
+ type: 'bearer',
+ bearer: [
+ {
+ key: 'token',
+ value: 'token',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+ // console.log('result', JSON.stringify(result, null, 2));
+
+ expect(result.root.request.auth).toEqual({
+ mode: 'bearer',
+ basic: null,
+ bearer: {
+ token: 'token'
+ },
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle API key auth at collection level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Collection level api key',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [],
+ auth: {
+ type: 'apikey',
+ apikey: [
+ {
+ key: 'value',
+ value: 'apikey',
+ type: 'string'
+ },
+ {
+ key: 'key',
+ value: 'apikey',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.root.request.auth).toEqual({
+ mode: 'apikey',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: {
+ key: 'apikey',
+ value: 'apikey',
+ placement: 'header'
+ },
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle digest auth at collection level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Collection level digest auth',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [],
+ auth: {
+ type: 'digest',
+ digest: [
+ {
+ key: 'password',
+ value: 'digest auth',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: 'digest auth',
+ type: 'string'
+ },
+ {
+ key: 'algorithm',
+ value: 'MD5',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.root.request.auth).toEqual({
+ mode: 'digest',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: {
+ username: 'digest auth',
+ password: 'digest auth'
+ }
+ });
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js
new file mode 100644
index 000000000..ba6f86596
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/folder-auth.spec.js
@@ -0,0 +1,247 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBruno from '../../../src/postman/postman-to-bruno';
+
+describe('Folder Authentication', () => {
+ it('should handle basic auth at folder level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Folder level basic auth',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'folder',
+ item: [],
+ auth: {
+ type: 'basic',
+ basic: [
+ {
+ key: 'password',
+ value: 'testpass',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: 'testuser',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].root.request.auth).toEqual({
+ mode: 'basic',
+ basic: {
+ username: 'testuser',
+ password: 'testpass'
+ },
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle bearer token auth at folder level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Folder level bearer token',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'folder',
+ item: [],
+ auth: {
+ type: 'bearer',
+ bearer: [
+ {
+ key: 'token',
+ value: 'token',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].root.request.auth).toEqual({
+ mode: 'bearer',
+ basic: null,
+ bearer: { token: 'token' },
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle API key auth at folder level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Folder level API key',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'folder',
+ item: [],
+ auth: {
+ type: 'apikey',
+ apikey: [
+ {
+ key: 'value',
+ value: 'apikey',
+ type: 'string'
+ },
+ {
+ key: 'key',
+ value: 'apikey',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].root.request.auth).toEqual({
+ mode: 'apikey',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: { key: 'apikey', value: 'apikey', placement: 'header' },
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should handle digest auth at folder level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Folder level digest auth',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'folder',
+ item: [],
+ auth: {
+ type: 'digest',
+ digest: [
+ {
+ key: 'password',
+ value: 'digest pass',
+ type: 'string'
+ },
+ {
+ key: 'username',
+ value: 'digest user',
+ type: 'string'
+ },
+ {
+ key: 'algorithm',
+ value: 'MD5',
+ type: 'string'
+ }
+ ]
+ },
+ event: [
+ {
+ listen: 'prerequest',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ },
+ {
+ listen: 'test',
+ script: {
+ type: 'text/javascript',
+ packages: {},
+ exec: ['']
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].root.request.auth).toEqual({
+ mode: 'digest',
+ basic: null,
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: { username: 'digest user', password: 'digest pass' }
+ });
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js
new file mode 100644
index 000000000..3ac79476c
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-to-bruno.spec.js
@@ -0,0 +1,187 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBruno from '../../../src/postman/postman-to-bruno';
+
+describe('postman-collection', () => {
+ it('should correctly import a valid Postman collection file', async () => {
+ const brunoCollection = await postmanToBruno(postmanCollection);
+ expect(brunoCollection).toMatchObject(expectedOutput);
+ });
+});
+
+// Simple Collection (postman)
+// ├── folder
+// │ └── request (GET)
+// └── request (GET)
+
+const postmanCollection = {
+ "info": {
+ "_postman_id": "7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9",
+ "name": "simple collection",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+ "_exporter_id": "21992467",
+ "_collection_link": "https://random-user-007.postman.co/workspace/testing~7523f559-3d5f-4c30-8315-3cb3c3ff98b7/collection/21992467-7f91bbd8-cb97-41ac-8d0b-e1fcd8bb4ce9?action=share&source=collection_link&creator=007"
+ },
+ "item": [
+ {
+ "name": "folder",
+ "item": [
+ {
+ "name": "request",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "https://usebruno.com",
+ "protocol": "https",
+ "host": [
+ "usebruno",
+ "com"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "request",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "https://usebruno.com",
+ "protocol": "https",
+ "host": [
+ "usebruno",
+ "com"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+};
+
+// Simple Collection (bruno)
+// ├── folder
+// │ └── request (GET)
+// └── request (GET)
+
+const expectedOutput = {
+ "name": "simple collection",
+ "uid": "mockeduuidvalue123456",
+ "version": "1",
+ "items": [
+ {
+ "uid": "mockeduuidvalue123456",
+ "name": "folder",
+ "type": "folder",
+ "seq": 1,
+ "items": [
+ {
+ "uid": "mockeduuidvalue123456",
+ "name": "request",
+ "type": "http-request",
+ "seq": 1,
+ "request": {
+ "url": "https://usebruno.com",
+ "method": "GET",
+ "auth": {
+ "mode": "none",
+ "basic": null,
+ "bearer": null,
+ "awsv4": null,
+ "apikey": null,
+ "oauth2": null,
+ "digest": null
+ },
+ "headers": [],
+ "params": [],
+ "body": {
+ "mode": "none",
+ "json": null,
+ "text": null,
+ "xml": null,
+ "formUrlEncoded": [],
+ "multipartForm": []
+ },
+ "docs": ""
+ }
+ }
+ ],
+ "root": {
+ "docs": "",
+ "meta": {
+ "name": "folder"
+ },
+ "request": {
+ "auth": {
+ "mode": "none",
+ "basic": null,
+ "bearer": null,
+ "awsv4": null,
+ "apikey": null,
+ "oauth2": null,
+ "digest": null
+ },
+ "headers": [],
+ "script": {},
+ "tests": "",
+ "vars": {}
+ }
+ }
+ },
+ {
+ "uid": "mockeduuidvalue123456",
+ "name": "request",
+ "type": "http-request",
+ "seq": 2,
+ "request": {
+ "url": "https://usebruno.com",
+ "method": "GET",
+ "auth": {
+ "mode": "none",
+ "basic": null,
+ "bearer": null,
+ "awsv4": null,
+ "apikey": null,
+ "oauth2": null,
+ "digest": null
+ },
+ "headers": [],
+ "params": [],
+ "body": {
+ "mode": "none",
+ "json": null,
+ "text": null,
+ "xml": null,
+ "formUrlEncoded": [],
+ "multipartForm": []
+ },
+ "docs": ""
+ },
+ }
+ ],
+ "environments": [],
+ "root": {
+ "docs": "",
+ "meta": {
+ "name": "simple collection"
+ },
+ "request": {
+ "auth": {
+ "mode": "none",
+ "basic": null,
+ "bearer": null,
+ "awsv4": null,
+ "apikey": null,
+ "oauth2": null,
+ "digest": null
+ },
+ "headers": [],
+ "script": {},
+ "tests": "",
+ "vars": {}
+ }
+ }
+ };
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-request.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-request.spec.js
new file mode 100644
index 000000000..93eed719a
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-request.spec.js
@@ -0,0 +1,29 @@
+const { default: postmanTranslation } = require("../../../../src/postman/postman-translations");
+
+describe('postmanTranslations - request commands', () => {
+ test('should handle request commands', () => {
+ const inputScript = `
+ const requestUrl = pm.request.url;
+ const requestMethod = pm.request.method;
+ const requestHeaders = pm.request.headers;
+ const requestBody = pm.request.body;
+ const requestName = pm.info.requestName;
+
+ pm.test('Request method is POST', function() {
+ pm.expect(pm.request.method).to.equal('POST');
+ });
+ `;
+ const expectedOutput = `
+ const requestUrl = req.getUrl();
+ const requestMethod = req.getMethod();
+ const requestHeaders = req.getHeaders();
+ const requestBody = req.getBody();
+ const requestName = req.getName();
+
+ test('Request method is POST', function() {
+ expect(req.getMethod()).to.equal('POST');
+ });
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-response.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-response.spec.js
new file mode 100644
index 000000000..a57b8435a
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/postman-translations/postman-response.spec.js
@@ -0,0 +1,34 @@
+const { default: postmanTranslation } = require("../../../../src/postman/postman-translations");
+
+describe('postmanTranslations - response commands', () => {
+ test('should handle response commands', () => {
+ const inputScript = `
+ const responseTime = pm.response.responseTime;
+ const responseCode = pm.response.code;
+ const responseText = pm.response.text();
+ const responseJson = pm.response.json();
+ const responseStatus = pm.response.status;
+ const responseHeaders = pm.response.headers;
+
+ pm.test('Status code is 200', function() {
+ pm.response.to.have.status(200);
+ });
+ `;
+ const expectedOutput = `
+ const responseTime = res.getResponseTime();
+ const responseCode = res.getStatus();
+ const responseText = JSON.stringify(res.getBody());
+ const responseJson = res.getBody();
+ const responseStatus = res.statusText;
+ const responseHeaders = res.getHeaders();
+
+ test('Status code is 200', function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
+
+
+
diff --git a/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js
new file mode 100644
index 000000000..2a71301d3
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-to-bruno/request-auth.spec.js
@@ -0,0 +1,134 @@
+import { describe, it, expect } from '@jest/globals';
+import postmanToBruno from '../../../src/postman/postman-to-bruno';
+
+describe('Request Authentication', () => {
+ it('should handle basic auth at request level', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Request Auth Collection',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'Basic Auth Request',
+ request: {
+ method: 'GET',
+ url: 'https://api.example.com/test',
+ auth: {
+ type: 'basic',
+ basic: [
+ { key: 'username', value: 'requestuser' },
+ { key: 'password', value: 'requestpass' }
+ ]
+ }
+ }
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].request.auth).toEqual({
+ mode: 'basic',
+ basic: {
+ username: 'requestuser',
+ password: 'requestpass'
+ },
+ bearer: null,
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should inherit folder auth when request has no auth', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Inherit Request Auth Collection',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'Auth Folder',
+ auth: {
+ type: 'bearer',
+ bearer: [{ key: 'token', value: 'foldertoken' }]
+ },
+ item: [
+ {
+ name: 'No Auth Request',
+ request: {
+ method: 'GET',
+ url: 'https://api.example.com/test'
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].items[0].request.auth).toEqual({
+ mode: 'bearer',
+ basic: null,
+ bearer: {
+ token: 'foldertoken'
+ },
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+ it('should override folder auth with request auth', async() => {
+ const postmanCollection = {
+ info: {
+ name: 'Override Request Auth Collection',
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
+ },
+ item: [
+ {
+ name: 'Auth Folder',
+ auth: {
+ type: 'basic',
+ basic: [
+ { key: 'username', value: 'folderuser' },
+ { key: 'password', value: 'folderpass' }
+ ]
+ },
+ item: [
+ {
+ name: 'Override Auth Request',
+ request: {
+ method: 'GET',
+ url: 'https://api.example.com/test',
+ auth: {
+ type: 'bearer',
+ bearer: [{ key: 'token', value: 'requesttoken' }]
+ }
+ }
+ }
+ ]
+ }
+ ]
+ };
+
+ const result = await postmanToBruno(postmanCollection);
+
+ expect(result.items[0].items[0].request.auth).toEqual({
+ mode: 'bearer',
+ basic: null,
+ bearer: {
+ token: 'requesttoken'
+ },
+ awsv4: null,
+ apikey: null,
+ oauth2: null,
+ digest: null
+ });
+ });
+
+});
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js
new file mode 100644
index 000000000..fed9f2931
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js
@@ -0,0 +1,53 @@
+const { default: postmanTranslation } = require("../../../src/postman/postman-translations");
+
+describe('postmanTranslations - comment handling', () => {
+ test('should not translate non-pm commands', () => {
+ const inputScript = `
+ console.log('This script does not contain pm commands.');
+ const data = pm.environment.get('key');
+ pm.collectionVariables.set('key', data);
+ `;
+ const expectedOutput = `
+ console.log('This script does not contain pm commands.');
+ const data = bru.getEnvVar('key');
+ bru.setVar('key', data);
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+
+ test('should comment non-translated pm commands', () => {
+ const inputScript = "pm.test('random test', () => pm.cookies.get('cookieName'));";
+ const expectedOutput = "// test('random test', () => pm.cookies.get('cookieName'));";
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+
+ test('should handle multiple pm commands on the same line', () => {
+ const inputScript = "pm.environment.get('key'); pm.environment.set('key', 'value');";
+ const expectedOutput = "bru.getEnvVar('key'); bru.setEnvVar('key', 'value');";
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+
+ test('should handle comments and other JavaScript code', () => {
+ const inputScript = `
+ // This is a comment
+ const value = 'test';
+ pm.environment.set('key', value);
+ /*
+ Multi-line comment
+ */
+ const result = pm.environment.get('key');
+ console.log('Result:', result);
+ `;
+ const expectedOutput = `
+ // This is a comment
+ const value = 'test';
+ bru.setEnvVar('key', value);
+ /*
+ Multi-line comment
+ */
+ const result = bru.getEnvVar('key');
+ console.log('Result:', result);
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js
new file mode 100644
index 000000000..d304026ea
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js
@@ -0,0 +1,47 @@
+const { default: postmanTranslation } = require("../../../src/postman/postman-translations");
+
+describe('postmanTranslations - edge cases', () => {
+ test('should handle nested commands and edge cases', () => {
+ const inputScript = `
+ const sampleObjects = [
+ {
+ key: pm.environment.get('key'),
+ value: pm.variables.get('value')
+ },
+ {
+ key: pm.collectionVariables.get('key'),
+ value: pm.collectionVariables.get('value')
+ }
+ ];
+ const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
+ // this is a comment
+ acc[key] = pm.collectionVariables.get(pm.environment.get(value));
+ return acc; // Return the accumulator
+ }, {});
+ Object.values(dataTesting).forEach((data) => {
+ pm.environment.set(data.key, pm.variables.get(data.value));
+ });
+ `;
+ const expectedOutput = `
+ const sampleObjects = [
+ {
+ key: bru.getEnvVar('key'),
+ value: bru.getVar('value')
+ },
+ {
+ key: bru.getVar('key'),
+ value: bru.getVar('value')
+ }
+ ];
+ const dataTesting = Object.entries(sampleObjects || {}).reduce((acc, [key, value]) => {
+ // this is a comment
+ acc[key] = bru.getVar(bru.getEnvVar(value));
+ return acc; // Return the accumulator
+ }, {});
+ Object.values(dataTesting).forEach((data) => {
+ bru.setEnvVar(data.key, bru.getVar(data.value));
+ });
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js
new file mode 100644
index 000000000..7c75b518e
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js
@@ -0,0 +1,23 @@
+const { default: postmanTranslation } = require("../../../src/postman/postman-translations");
+
+describe('postmanTranslations - test commands', () => {
+ test('should handle test commands', () => {
+ const inputScript = `
+ pm.test('Status code is 200', () => {
+ pm.response.to.have.status(200);
+ });
+ pm.test('this test will fail', () => {
+ return false
+ });
+ `;
+ const expectedOutput = `
+ test('Status code is 200', () => {
+ expect(res.getStatus()).to.equal(200);
+ });
+ test('this test will fail', () => {
+ return false
+ });
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js
new file mode 100644
index 000000000..70801f295
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js
@@ -0,0 +1,25 @@
+const { default: postmanTranslation } = require("../../../src/postman/postman-translations");
+
+describe('postmanTranslations - variables commands', () => {
+ test('should translate variable commands correctly', () => {
+ const inputScript = `
+ pm.environment.get('key');
+ pm.environment.set('key', 'value');
+ pm.variables.get('key');
+ pm.variables.set('key', 'value');
+ pm.collectionVariables.get('key');
+ pm.collectionVariables.set('key', 'value');
+ pm.expect(pm.environment.has('key')).to.be.true;
+ `;
+ const expectedOutput = `
+ bru.getEnvVar('key');
+ bru.setEnvVar('key', 'value');
+ bru.getVar('key');
+ bru.setVar('key', 'value');
+ bru.getVar('key');
+ bru.setVar('key', 'value');
+ expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
+ `;
+ expect(postmanTranslation(inputScript)).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js
new file mode 100644
index 000000000..4916a10c0
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js
@@ -0,0 +1,495 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Combined API Features Translation', () => {
+ // Basic translation test
+ it('should translate code', () => {
+ const code = 'console.log("Hello, world!");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(code);
+ });
+
+ // Preserving comments
+ it('should preserve comments', () => {
+ const code = '// This is a comment';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('// This is a comment');
+ });
+
+ it('should preserve comments inside functions', () => {
+ const code = `
+ function getUserDetails() {
+ // Get user details from API
+ const response = pm.response.json();
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ function getUserDetails() {
+ // Get user details from API
+ const response = res.getBody();
+ }
+ `);
+ });
+
+ it('should preserve comments inside if statements', () => {
+ const code = `
+ if (pm.response.code === 200) {
+ // Success
+ console.log("Success");
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ if (res.getStatus() === 200) {
+ // Success
+ console.log("Success");
+ }
+ `);
+ });
+
+ it('should preserve multiline comments', () => {
+ const code = `
+ /*
+ This is a multiline comment
+ */
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ /*
+ This is a multiline comment
+ */
+ `);
+ });
+
+ it('should preserve comments inside for loops', () => {
+ const code = `
+ for (let i = 0; i < 10; i++) {
+ // Loop iteration
+ console.log(pm.response.json()[i]);
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ for (let i = 0; i < 10; i++) {
+ // Loop iteration
+ console.log(res.getBody()[i]);
+ }
+ `);
+ });
+
+ // Multiple transformations in the same code block
+ it('should handle multiple translations in the same code block', () => {
+ const code = `
+ const token = pm.environment.get("authToken");
+ pm.test("Auth flow works", function() {
+ const response = pm.response.json();
+ pm.expect(response.authenticated).to.be.true;
+ pm.environment.set("userId", response.user.id);
+ pm.collectionVariables.set("sessionId", response.session.id);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).not.toContain('pm.test("Auth flow works", function() {');
+ expect(translatedCode).not.toContain('pm.expect(response.authenticated).to.be.true;');
+ expect(translatedCode).not.toContain('pm.environment.set("userId", response.user.id);');
+ expect(translatedCode).not.toContain('pm.collectionVariables.set("sessionId", response.session.id);');
+ expect(translatedCode).toContain('const token = bru.getEnvVar("authToken");');
+ expect(translatedCode).toContain('test("Auth flow works", function() {');
+ expect(translatedCode).toContain('const response = res.getBody();');
+ expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
+ expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
+ expect(translatedCode).toContain('bru.setVar("sessionId", response.session.id);');
+ });
+
+ // Nested expressions
+ it('should handle nested Postman API calls', () => {
+ const code = 'pm.environment.set("computed", pm.variables.get("base") + "-suffix");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
+ });
+
+ it('should handle more complex nested expressions', () => {
+ const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
+ });
+
+ // Unrelated code
+ it('should leave unrelated code untouched', () => {
+ const code = `
+ function calculateTotal(items) {
+ return items.reduce((sum, item) => sum + item.price, 0);
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(code);
+ });
+
+ it('should handle Postman API calls within JavaScript methods', () => {
+ const code = `
+ const helpers = {
+ getAuthHeader: function() {
+ return "Bearer " + pm.environment.get("token");
+ }
+ };
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('return "Bearer " + bru.getEnvVar("token");');
+ });
+
+
+ it('should handle aliases with object destructuring', () => {
+ const code = `
+ const { environment, variables } = pm;
+ environment.set("token", "abc123");
+ variables.get("userId");
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ bru.setEnvVar("token", "abc123");
+ bru.getVar("userId");
+ `);
+ });
+
+ // Code context tests
+ it('should translate pm commands inside functions', () => {
+ const code = `
+ function getAuthHeader() {
+ return "Bearer " + pm.environment.get("token");
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ function getAuthHeader() {
+ return "Bearer " + bru.getEnvVar("token");
+ }
+ `);
+ });
+
+ it('should translate pm commands inside if statements', () => {
+ const code = `
+ if (pm.response.code === 200) {
+ console.log("Success");
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ if (res.getStatus() === 200) {
+ console.log("Success");
+ }
+ `);
+ });
+
+
+ it('should translate pm commands inside if statements', () => {
+ const code = `
+ const json = pm.response.json();
+ if (json.code === 200) {
+ console.log("Success");
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const json = res.getBody();
+ if (json.code === 200) {
+ console.log("Success");
+ }
+ `);
+ });
+
+ it('should translate pm commands inside else statements', () => {
+ const code = `
+ if (pm.response.code === 200) {
+ console.log("Success");
+ pm.response.to.have.status(200);
+ } else {
+ console.log("Failure");
+ expect(res.getStatus()).to.equal(400);
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ if (res.getStatus() === 200) {
+ console.log("Success");
+ expect(res.getStatus()).to.equal(200);
+ } else {
+ console.log("Failure");
+ expect(res.getStatus()).to.equal(400);
+ }
+ `);
+ });
+
+ it('should translate pm commands inside for loops', () => {
+ const code = `
+ for (let i = 0; i < pm.response.json().length; i++) {
+ console.log(pm.response.json()[i]);
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ for (let i = 0; i < res.getBody().length; i++) {
+ console.log(res.getBody()[i]);
+ }
+ `);
+ });
+
+ it('should translate pm commands inside while loops', () => {
+ const code = `
+ while (pm.response.code === 200) {
+ console.log("Success");
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ while (res.getStatus() === 200) {
+ console.log("Success");
+ }
+ `);
+ });
+
+ it('should translate pm commands inside switch statements', () => {
+ const code = `
+ switch (pm.response.code) {
+ case 200:
+ console.log("Success");
+ break;
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ switch (res.getStatus()) {
+ case 200:
+ console.log("Success");
+ break;
+ }
+ `);
+ });
+
+ it('should translate pm commands inside try catch statements', () => {
+ const code = `
+ try {
+ pm.response.to.have.status(200);
+ } catch (error) {
+ console.log("Failure");
+ expect(res.getStatus()).to.equal(400);
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ try {
+ expect(res.getStatus()).to.equal(200);
+ } catch (error) {
+ console.log("Failure");
+ expect(res.getStatus()).to.equal(400);
+ }
+ `);
+ });
+
+ it('should translate aliases within if statements block', () => {
+ const code = `
+ const env = pm.environment;
+ const vars = pm.variables;
+ const collVars = pm.collectionVariables;
+ const test = pm.test;
+ const expect = pm.expect;
+ const response = pm.response;
+
+ function processResponse() {
+ if(response.code === 200) {
+ console.log("Success");
+ } else if(response.code === 400) {
+ console.log("Failure");
+ expect(response.code).to.equal(400);
+ } else {
+ console.log("Unknown status code");
+ expect(response.code).to.equal(500);
+ }
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ function processResponse() {
+ if(res.getStatus() === 200) {
+ console.log("Success");
+ } else if(res.getStatus() === 400) {
+ console.log("Failure");
+ expect(res.getStatus()).to.equal(400);
+ } else {
+ console.log("Unknown status code");
+ expect(res.getStatus()).to.equal(500);
+ }
+ }
+ `);
+ });
+
+ it('should handle pm aliases inside functions', () => {
+ const code = `
+ const tempRes = pm.response;
+ const tempTest = pm.test;
+ const tempExpect = pm.expect;
+ const tempEnv = pm.environment;
+ const tempVars = pm.variables;
+ const tempCollVars = pm.collectionVariables;
+
+ function processResponse() {
+ tempTest("Status code is 200", function() { expect(tempRes.code).to.equal(200); });
+ tempEnv.set("userId", tempRes.json().userId);
+ tempVars.set("token", tempRes.json().token);
+ tempCollVars.set("sessionId", tempRes.json().sessionId);
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ function processResponse() {
+ test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });
+ bru.setEnvVar("userId", res.getBody().userId);
+ bru.setVar("token", res.getBody().token);
+ bru.setVar("sessionId", res.getBody().sessionId);
+ }
+ `);
+ });
+
+ it('should nested pm commands', () => {
+ const code = `
+ pm.collectionVariables.get(pm.environment.get('key'))
+ pm.test("Status code is 200", function() {
+ pm.response.to.have.status(200);
+ });
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ bru.getVar(bru.getEnvVar('key'))
+ test("Status code is 200", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+ `);
+ });
+
+ it('should handle pm objects in template literals', () => {
+ const code = `
+ const baseUrl = pm.environment.get("baseUrl");
+ const endpoint = pm.variables.get("endpoint");
+ const url = \`\${baseUrl}/api/\${endpoint}\`;
+ console.log(\`Response status: \${pm.response.code}\`);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
+ expect(translatedCode).toContain('const endpoint = bru.getVar("endpoint");');
+ expect(translatedCode).toContain('const url = `${baseUrl}/api/${endpoint}`;');
+ expect(translatedCode).toContain('console.log(`Response status: ${res.getStatus()}`);');
+ });
+
+ it('should handle pm objects in arrow functions', () => {
+ const code = `
+ const getAuthHeader = () => "Bearer " + pm.environment.get("token");
+ const processItems = items => items.forEach(item => {
+ pm.variables.set(item.key, item.value);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const getAuthHeader = () => "Bearer " + bru.getEnvVar("token");');
+ expect(translatedCode).toContain('const processItems = items => items.forEach(item => {');
+ expect(translatedCode).toContain('bru.setVar(item.key, item.value);');
+ });
+
+ it('test', () => {
+ const code = `
+ const globals = pm.globals;
+ const key = globals.get("key");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const key = bru.getGlobalEnvVar("key");
+ `);
+ })
+
+ it('should handle pm.response.to.have.body integrated with other assertions', () => {
+ const code = `
+ pm.test("Response validation", function() {
+ pm.response.to.have.status(200);
+ pm.response.to.have.body({"success": true});
+ pm.response.to.have.header("Content-Type", "application/json");
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ const expectedOutput = `
+ test("Response validation", function() {
+ expect(res.getStatus()).to.equal(200);
+ expect(res.getBody()).to.equal({"success": true});
+ expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");
+ });
+ `;
+ expect(translatedCode).toBe(expectedOutput);
+ });
+
+ it('should handle pm.response.to.have.body with dynamic content', () => {
+ const code = `
+ const expectedResponse = {
+ id: pm.environment.get("userId"),
+ token: pm.variables.get("authToken"),
+ timestamp: new Date().getTime()
+ };
+
+ pm.test("Dynamic response validation", function() {
+ pm.response.to.have.body(expectedResponse);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ const expectedOutput = `
+ const expectedResponse = {
+ id: bru.getEnvVar("userId"),
+ token: bru.getVar("authToken"),
+ timestamp: new Date().getTime()
+ };
+
+ test("Dynamic response validation", function() {
+ expect(res.getBody()).to.equal(expectedResponse);
+ });
+ `
+ expect(translatedCode).toBe(expectedOutput);
+ });
+
+ it('should handle pm.response.to.have.body in control structures', () => {
+ const code = `
+ const jsonData = pm.response.json();
+
+ if (jsonData.status === "success") {
+ pm.response.to.have.body({
+ status: "success",
+ data: jsonData.data
+ });
+ } else {
+ pm.expect(jsonData.error).to.exist;
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ const expectedOutput = `
+ const jsonData = res.getBody();
+
+ if (jsonData.status === "success") {
+ expect(res.getBody()).to.equal({
+ status: "success",
+ data: jsonData.data
+ });
+ } else {
+ expect(jsonData.error).to.exist;
+ }
+ `;
+ expect(translatedCode).toBe(expectedOutput);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js
new file mode 100644
index 000000000..c3461f6de
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js
@@ -0,0 +1,242 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Environment Variable Translation', () => {
+ it('should translate pm.environment.get', () => {
+ const code = 'pm.environment.get("test");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.getEnvVar("test");');
+ });
+
+ it('should translate pm.environment.set', () => {
+ const code = 'pm.environment.set("test", "value");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("test", "value");');
+ });
+
+ it('should translate pm.environment.has', () => {
+ const code = 'pm.environment.has("test")';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.getEnvVar("test") !== undefined && bru.getEnvVar("test") !== null');
+ });
+
+ it('should translate pm.environment.unset', () => {
+ const code = 'pm.environment.unset("test");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.deleteEnvVar("test");');
+ });
+
+ it('should translate pm.environment.name', () => {
+ const code = 'pm.environment.name;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.getEnvName();');
+ });
+
+ it('should handle nested Postman API calls with environment', () => {
+ const code = 'pm.environment.set("computed", pm.variables.get("base") + "-suffix");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
+ });
+
+ it('should handle JSON operations with environment variables', () => {
+ const code = 'pm.environment.set("user", JSON.stringify({ id: 123, name: "John" }));';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("user", JSON.stringify({ id: 123, name: "John" }));');
+ });
+
+ it('should handle JSON.parse with environment variables', () => {
+ const code = 'const userData = JSON.parse(pm.environment.get("user"));';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const userData = JSON.parse(bru.getEnvVar("user"));');
+ });
+
+ it('should translate pm.environment.name with different access patterns', () => {
+ const code = `
+ const envName1 = pm.environment.name;
+ const env = pm.environment;
+ const envName2 = env.name;
+ console.log(pm.environment.name);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const envName1 = bru.getEnvName();
+ const envName2 = bru.getEnvName();
+ console.log(bru.getEnvName());
+ `);
+ });
+
+ it('should handle environment aliases', () => {
+ const code = `
+ const env = pm.environment;
+ const name = env.name;
+ const has = env.has("test");
+ const set = env.set("test", "value");
+ const get = env.get("test");
+ const unset = env.unset("test");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const name = bru.getEnvName();
+ const has = bru.getEnvVar("test") !== undefined && bru.getEnvVar("test") !== null;
+ const set = bru.setEnvVar("test", "value");
+ const get = bru.getEnvVar("test");
+ const unset = bru.deleteEnvVar("test");
+ `);
+ });
+
+ // Legacy API (postman.) tests related to environment
+ it('should translate postman.setEnvironmentVariable', () => {
+ const code = 'postman.setEnvironmentVariable("apiKey", "abc123");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("apiKey", "abc123");');
+ });
+
+ it('should translate postman.getEnvironmentVariable', () => {
+ const code = 'const baseUrl = postman.getEnvironmentVariable("baseUrl");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const baseUrl = bru.getEnvVar("baseUrl");');
+ });
+
+ it('should translate postman.clearEnvironmentVariable', () => {
+ const code = 'postman.clearEnvironmentVariable("tempToken");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.deleteEnvVar("tempToken");');
+ });
+
+ it('should handle all environment variable methods together', () => {
+ const code = `
+ // All environment variable methods
+ const envName = pm.environment.name;
+ const hasToken = pm.environment.has("token");
+ const token = pm.environment.get("token");
+ pm.environment.set("timestamp", new Date().toISOString());
+
+ console.log(\`Environment: \${envName}, Has token: \${hasToken}, Token: \${token}\`);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const envName = bru.getEnvName();');
+ expect(translatedCode).toContain('const hasToken = bru.getEnvVar("token") !== undefined && bru.getEnvVar("token") !== null;');
+ expect(translatedCode).toContain('const token = bru.getEnvVar("token");');
+ expect(translatedCode).toContain('bru.setEnvVar("timestamp", new Date().toISOString());');
+ });
+
+ // Additional robust tests for environment variables
+ it('should handle environment variables with computed property names', () => {
+ const code = `
+ const prefix = "api";
+ const suffix = "Key";
+ pm.environment.set(prefix + "_" + suffix, "abc123");
+ const computedValue = pm.environment.get(prefix + "_" + suffix);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('bru.setEnvVar(prefix + "_" + suffix, "abc123");');
+ expect(translatedCode).toContain('const computedValue = bru.getEnvVar(prefix + "_" + suffix);');
+ });
+
+ it('should handle environment variables in complex object structures', () => {
+ const code = `
+ const config = {
+ baseUrl: pm.environment.get("apiUrl"),
+ headers: {
+ "Authorization": "Bearer " + pm.environment.get("token"),
+ "X-Api-Key": pm.environment.get("apiKey") || "default-key"
+ },
+ timeout: parseInt(pm.environment.get("timeout") || "5000"),
+ validate: pm.environment.has("validateResponses")
+ };
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('baseUrl: bru.getEnvVar("apiUrl"),');
+ expect(translatedCode).toContain('"Authorization": "Bearer " + bru.getEnvVar("token"),');
+ expect(translatedCode).toContain('"X-Api-Key": bru.getEnvVar("apiKey") || "default-key"');
+ expect(translatedCode).toContain('timeout: parseInt(bru.getEnvVar("timeout") || "5000"),');
+ expect(translatedCode).toContain('validate: bru.getEnvVar("validateResponses") !== undefined && bru.getEnvVar("validateResponses") !== null');
+ });
+
+ it('should handle environment variables in conditionals correctly', () => {
+ const code = `
+ if (pm.environment.has("apiKey")) {
+ if (pm.environment.get("apiKey").length > 0) {
+ console.log("Valid API key exists");
+ } else {
+ console.log("API key is empty");
+ }
+ } else {
+ console.log("No API key defined");
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null) {');
+ expect(translatedCode).toContain('if (bru.getEnvVar("apiKey").length > 0) {');
+ });
+
+ it('should handle multiple levels of environment variable aliasing', () => {
+ const code = `
+ const env = pm.environment;
+
+ env.set("key", "value");
+ const value = env.get("key");
+ const exists = env.has("key");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ bru.setEnvVar("key", "value");
+ const value = bru.getEnvVar("key");
+ const exists = bru.getEnvVar("key") !== undefined && bru.getEnvVar("key") !== null;
+ `);
+ });
+
+ it('should handle environment variables with dynamic values', () => {
+ const code = `
+ // Generate a timestamp for this request
+ const timestamp = new Date().toISOString();
+ pm.environment.set("requestTimestamp", timestamp);
+
+ // Generate a unique ID
+ const uniqueId = "req_" + Math.random().toString(36).substring(2, 15);
+ pm.environment.set("requestId", uniqueId);
+
+ // Calculate an expiry time (30 minutes from now)
+ const expiryTime = new Date();
+ expiryTime.setMinutes(expiryTime.getMinutes() + 30);
+ pm.environment.set("tokenExpiry", expiryTime.getTime());
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('bru.setEnvVar("requestTimestamp", timestamp);');
+ expect(translatedCode).toContain('bru.setEnvVar("requestId", uniqueId);');
+ expect(translatedCode).toContain('bru.setEnvVar("tokenExpiry", expiryTime.getTime());');
+ });
+
+ it('should handle environment variables in try-catch blocks', () => {
+ const code = `
+ try {
+ const configStr = pm.environment.get("config");
+ const config = JSON.parse(configStr);
+ console.log("Config loaded:", config.version);
+ } catch (error) {
+ console.error("Failed to parse config");
+ pm.environment.set("configError", error.message);
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const configStr = bru.getEnvVar("config");');
+ expect(translatedCode).toContain('bru.setEnvVar("configError", error.message);');
+ });
+
+ it('should handle legacy environment and pm.setEnvironmentVariable together', () => {
+ const code = `
+ // Legacy style
+ postman.setEnvironmentVariable("legacyKey", "legacyValue");
+
+ // Mixed with newer style
+ const value = pm.environment.get("anotherKey");
+
+ // Another legacy form
+ pm.setEnvironmentVariable("thirdKey", "thirdValue");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('bru.setEnvVar("legacyKey", "legacyValue");');
+ expect(translatedCode).toContain('const value = bru.getEnvVar("anotherKey");');
+ expect(translatedCode).toContain('bru.setEnvVar("thirdKey", "thirdValue");');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js
new file mode 100644
index 000000000..053e99685
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js
@@ -0,0 +1,64 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Execution Flow Translation', () => {
+ // Request flow control
+ it('should translate pm.setNextRequest', () => {
+ const code = 'pm.setNextRequest("Get User Details");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setNextRequest("Get User Details");');
+ });
+
+ it('should translate pm.execution.skipRequest', () => {
+ const code = 'if (condition) pm.execution.skipRequest();';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('if (condition) bru.runner.skipRequest();');
+ });
+
+ it('should translate pm.execution.setNextRequest(null)', () => {
+ const code = 'pm.execution.setNextRequest(null);';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.runner.stopExecution();');
+ });
+
+ it('should translate pm.execution.setNextRequest("null")', () => {
+ const code = 'pm.execution.setNextRequest("null");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.runner.stopExecution();');
+ });
+
+ it('should handle pm.execution.setNextRequest with non-null parameters', () => {
+ const code = `
+ // Continue normal flow
+ pm.execution.setNextRequest("Get user details");
+
+ // With variable
+ const nextReq = "Update profile";
+ pm.execution.setNextRequest(nextReq);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('bru.runner.setNextRequest("Get user details");');
+ expect(translatedCode).toContain('bru.runner.setNextRequest(nextReq);');
+ });
+
+ it('should handle all execution control methods together', () => {
+ const code = `
+ // All execution control methods
+ if (pm.response.code === 401) {
+ pm.execution.skipRequest();
+ } else if (pm.response.code === 500) {
+ pm.execution.setNextRequest(null);
+ } else {
+ pm.setNextRequest("Get User Details");
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('if (res.getStatus() === 401) {');
+ expect(translatedCode).toContain('bru.runner.skipRequest();');
+ expect(translatedCode).toContain('} else if (res.getStatus() === 500) {');
+ expect(translatedCode).toContain('bru.runner.stopExecution();');
+ expect(translatedCode).toContain('} else {');
+ expect(translatedCode).toContain('bru.setNextRequest("Get User Details");');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js
new file mode 100644
index 000000000..34c6f32a6
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js
@@ -0,0 +1,283 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Legacy Tests[] Syntax Translation', () => {
+ it('should handle tests[] commands', () => {
+ const code = `
+ tests["Status code is 200"] = pm.response.code === 200;`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Status code is 200", function() {
+ expect(Boolean(res.getStatus() === 200)).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with complex expressions', () => {
+ const code = `
+ tests["Response has valid data"] = pm.response.json().data && pm.response.json().data.length > 0;`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Response has valid data", function() {
+ expect(Boolean(res.getBody().data && res.getBody().data.length > 0)).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with string equality', () => {
+ const code = `
+ tests["Content-Type is application/json"] = pm.response.headers.get("Content-Type") === "application/json";`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Content-Type is application/json", function() {
+ expect(Boolean(res.getHeader("Content-Type") === "application/json")).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with function calls', () => {
+ const code = `
+ tests["Response time is acceptable"] = pm.response.responseTime < 500;`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Response time is acceptable", function() {
+ expect(Boolean(res.getResponseTime() < 500)).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with variable references', () => {
+ const code = `
+ const expectedStatus = 201;
+ tests["Status code is correct"] = pm.response.code === expectedStatus;`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const expectedStatus = 201;
+ test("Status code is correct", function() {
+ expect(Boolean(res.getStatus() === expectedStatus)).to.be.true;
+ });`);
+ });
+
+ it('should handle multiple tests[] statements', () => {
+ const code = `
+ tests["Status code is 200"] = pm.response.code === 200;
+ tests["Response has data"] = pm.response.json().hasOwnProperty("data");`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Status code is 200", function() {
+ expect(Boolean(res.getStatus() === 200)).to.be.true;
+ });
+ test("Response has data", function() {
+ expect(Boolean(res.getBody().hasOwnProperty("data"))).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with special characters in name', () => {
+ const code = `
+ tests["Special characters: !@#$%^&*()"] = true;`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Special characters: !@#$%^&*()", function() {
+ expect(Boolean(true)).to.be.true;
+ });`);
+ });
+
+ it('should handle tests[] with pm.environment variables', () => {
+ const code = `
+ tests["Response matches environment variable"] = pm.response.json().id === pm.environment.get("expectedId");`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Response matches environment variable", function() {
+ expect(Boolean(res.getBody().id === bru.getEnvVar("expectedId"))).to.be.true;
+ });`);
+ });
+
+ it('should handle nested pm objects in tests[] assignments', () => {
+ const code = `
+ tests["Authentication header is present"] = pm.request.headers.has("Authorization");
+ tests["Data count is correct"] = pm.response.json().items.length === pm.variables.get("expectedCount");
+ `;
+ const translatedCode = translateCode(code);
+
+ // The exact translation might vary depending on implementation details,
+ // but we can check for key transformations
+ expect(translatedCode).toContain('test("Authentication header is present"');
+ expect(translatedCode).toContain('test("Data count is correct"');
+ expect(translatedCode).toContain('res.getBody().items.length === bru.getVar("expectedCount")');
+ });
+
+ // Additional robust tests for legacy tests[] syntax
+ it('should handle tests[] with complex boolean expressions', () => {
+ const code = `
+ tests["Complex validation"] = (pm.response.code >= 200 && pm.response.code < 300) ||
+ (pm.response.json().success === true && pm.response.json().data !== null);`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Complex validation", function() {');
+ expect(translatedCode).toContain('expect(Boolean((res.getStatus() >= 200 && res.getStatus() < 300) ||');
+ expect(translatedCode).toContain('(res.getBody().success === true && res.getBody().data !== null))).to.be.true;');
+ });
+
+ it('should handle tests[] with array methods', () => {
+ const code = `
+ tests["All items have an ID"] = pm.response.json().items.every(item => item.hasOwnProperty('id'));
+ tests["Has premium item"] = pm.response.json().items.some(item => item.type === 'premium');`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("All items have an ID", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getBody().items.every(item => item.hasOwnProperty(\'id\')))).to.be.true;');
+ expect(translatedCode).toContain('test("Has premium item", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getBody().items.some(item => item.type === \'premium\'))).to.be.true;');
+ });
+
+ it('should handle tests[] with template literals in the name', () => {
+ const code = `
+ const endpoint = "users";
+ tests[\`Endpoint \${endpoint} returns valid response\`] = pm.response.code === 200;`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const endpoint = "users";');
+ expect(translatedCode).toContain('test(`Endpoint ${endpoint} returns valid response`, function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
+ });
+
+ it('should handle tests[] with deep property access', () => {
+ const code = `
+ tests["User has admin role"] = pm.response.json().user &&
+ pm.response.json().user.roles &&
+ pm.response.json().user.roles.includes('admin');`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("User has admin role", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getBody().user &&');
+ expect(translatedCode).toContain('res.getBody().user.roles &&');
+ expect(translatedCode).toContain('res.getBody().user.roles.includes(\'admin\'))).to.be.true;');
+ });
+
+ it('should handle tests[] with JSON schema validation patterns', () => {
+ const code = `
+ const schema = {
+ type: "object",
+ required: ["id", "name"],
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" }
+ }
+ };
+
+ const data = pm.response.json();
+
+ // Basic schema validation patterns
+ tests["Has required fields"] = data.hasOwnProperty('id') && data.hasOwnProperty('name');
+ tests["ID is string"] = typeof data.id === 'string';
+ tests["Name is string"] = typeof data.name === 'string';`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const schema = {');
+ expect(translatedCode).toContain('type: "object",');
+ expect(translatedCode).toContain('required: ["id", "name"],');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('test("Has required fields", function() {');
+ expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'id\') && data.hasOwnProperty(\'name\'))).to.be.true;');
+ expect(translatedCode).toContain('test("ID is string", function() {');
+ expect(translatedCode).toContain('expect(Boolean(typeof data.id === \'string\')).to.be.true;');
+ });
+
+ it('should handle tests[] within conditional blocks', () => {
+ const code = `
+ const data = pm.response.json();
+
+ if (pm.response.code === 200) {
+ tests["Success response has data"] = data.hasOwnProperty('items');
+
+ if (data.items.length > 0) {
+ tests["First item has ID"] = data.items[0].hasOwnProperty('id');
+ }
+ } else {
+ tests["Error response has message"] = data.hasOwnProperty('message');
+ }`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('if (res.getStatus() === 200) {');
+ expect(translatedCode).toContain('test("Success response has data", function() {');
+ expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'items\'))).to.be.true;');
+ expect(translatedCode).toContain('if (data.items.length > 0) {');
+ expect(translatedCode).toContain('test("First item has ID", function() {');
+ expect(translatedCode).toContain('expect(Boolean(data.items[0].hasOwnProperty(\'id\'))).to.be.true;');
+ expect(translatedCode).toContain('} else {');
+ expect(translatedCode).toContain('test("Error response has message", function() {');
+ expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'message\'))).to.be.true;');
+ });
+
+ it('should handle tests[] with combination of legacy and modern styles', () => {
+ const code = `
+ // Legacy style
+ tests["Status code is 200"] = pm.response.code === 200;
+
+ // Modern style
+ pm.test("Response has valid data", function() {
+ const json = pm.response.json();
+ pm.expect(json).to.be.an('object');
+ pm.expect(json.items).to.be.an('array');
+
+ // Mix by using tests[] inside pm.test
+ tests["All items have price"] = json.items.every(item => item.hasOwnProperty('price'));
+ });`;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Status code is 200", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
+ expect(translatedCode).toContain('test("Response has valid data", function() {');
+ expect(translatedCode).toContain('const json = res.getBody();');
+ expect(translatedCode).toContain('expect(json).to.be.an(\'object\');');
+ expect(translatedCode).toContain('expect(json.items).to.be.an(\'array\');');
+ expect(translatedCode).toContain('test("All items have price", function() {');
+ expect(translatedCode).toContain('expect(Boolean(json.items.every(item => item.hasOwnProperty(\'price\')))).to.be.true;');
+ });
+
+ it('should handle complex real-world tests[] example', () => {
+ const code = `
+ // Parse response
+ const response = pm.response.json();
+
+ // Basic response validation
+ tests["Status code is 200"] = pm.response.code === 200;
+ tests["Response is valid JSON"] = response !== null && typeof response === 'object';
+
+ // Check headers
+ tests["Has content-type header"] = pm.response.headers.has("Content-Type");
+ tests["Content-Type is JSON"] = pm.response.headers.get("Content-Type").includes("application/json");
+
+ // Validate against expected values
+ const expectedItems = parseInt(pm.environment.get("expectedItemCount"));
+ tests["Has correct number of items"] = response.items.length === expectedItems;
+
+ // Check for required fields on all items
+ const requiredFields = ["id", "name", "price", "category"];
+ tests["All items have required fields"] = response.items.every(item => {
+ return requiredFields.every(field => item.hasOwnProperty(field));
+ });
+
+ // Validate specific business rules
+ tests["No items with zero price"] = response.items.every(item => parseFloat(item.price) > 0);
+ tests["Has at least one featured item"] = response.items.some(item => item.featured === true);
+
+ // If we find a specific item we're looking for, save its ID for later
+ const targetItem = response.items.find(item => item.name === pm.variables.get("targetItemName"));
+ if (targetItem) {
+ pm.environment.set("targetItemId", targetItem.id);
+ tests["Found target item"] = true;
+ }`;
+ const translatedCode = translateCode(code);
+
+ // Check key transformations
+ expect(translatedCode).toContain('const response = res.getBody();');
+ expect(translatedCode).toContain('test("Status code is 200", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
+ expect(translatedCode).toContain('test("Has content-type header", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getHeaders().has("Content-Type"))).to.be.true;');
+ expect(translatedCode).toContain('test("Content-Type is JSON", function() {');
+ expect(translatedCode).toContain('expect(Boolean(res.getHeader("Content-Type").includes("application/json"))).to.be.true;');
+ expect(translatedCode).toContain('const expectedItems = parseInt(bru.getEnvVar("expectedItemCount"));');
+ expect(translatedCode).toContain('test("Has correct number of items", function() {');
+ expect(translatedCode).toContain('expect(Boolean(response.items.length === expectedItems)).to.be.true;');
+ expect(translatedCode).toContain('const targetItem = response.items.find(item => item.name === bru.getVar("targetItemName"));');
+ expect(translatedCode).toContain('bru.setEnvVar("targetItemId", targetItem.id);');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js
new file mode 100644
index 000000000..a9be82130
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js
@@ -0,0 +1,283 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Multiline Syntax Handling', () => {
+ it('should handle basic multiline variable syntax with indentation', () => {
+ const code = `
+ const userId = pm.variables
+ .get("userId");
+ pm.variables
+ .set("timestamp", new Date().toISOString());
+ const hasToken = pm.variables
+ .has("token");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const userId = bru.getVar("userId");
+ bru.setVar("timestamp", new Date().toISOString());
+ const hasToken = bru.hasVar("token");
+ `);
+ });
+
+ it('should handle multiline environment variable syntax', () => {
+ const code = `
+ const baseUrl = pm
+ .environment
+ .get("baseUrl");
+ pm
+ .environment
+ .set("requestTime", Date.now());
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const baseUrl = bru.getEnvVar("baseUrl");
+ bru.setEnvVar("requestTime", Date.now());
+ `);
+ });
+
+ it('should handle multiline collection variable syntax', () => {
+ const code = `
+ const apiKey = pm.collectionVariables
+ .get("apiKey");
+ pm.collectionVariables
+ .set("lastRun", new Date().toISOString());
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const apiKey = bru.getVar("apiKey");
+ bru.setVar("lastRun", new Date().toISOString());
+ `);
+ });
+
+ it('should handle complex environment.has transformation with multiline syntax', () => {
+ const code = `
+ if (pm.environment
+ .has("apiKey")) {
+ console.log("API Key exists");
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null) {
+ console.log("API Key exists");
+ }
+ `);
+ });
+
+ it('should handle response.to.have.status with multiline formatting', () => {
+ const code = `
+ pm.test("Status code is correct", function() {
+ pm
+ .response
+ .to
+ .have
+ .status(200);
+ });
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
+ });
+
+ it('should handle response.to.have.header with multiline formatting', () => {
+ const code = `
+ pm.test("Content type is present", function() {
+ pm
+ .response
+ .to
+ .have
+ .header("content-type");
+ });
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("content-type".toLowerCase())');
+ });
+
+ it('should handle response properties with multiline syntax', () => {
+ const code = `
+ const responseBody = pm
+ .response
+ .json();
+ const responseText = pm
+ .response
+ .text;
+ const responseTime = pm
+ .response
+ .responseTime;
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const responseBody = res.getBody()');
+ expect(translatedCode).toContain('const responseText = ');
+ expect(translatedCode).toContain('const responseTime = res.getResponseTime()');
+ });
+
+ it('should handle execution flow control with multiline syntax', () => {
+ const code = `
+ // Stop execution
+ pm
+ .execution
+ .setNextRequest(null);
+
+ // Continue to next request
+ pm
+ .execution
+ .setNextRequest("Next API Call");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('// Stop execution');
+ expect(translatedCode).toContain('// Continue to next request');
+ expect(translatedCode).toContain('bru.runner.stopExecution()');
+ expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
+ });
+
+ it('should handle mixed normal and multiline syntax in the same code', () => {
+ const code = `
+ // Normal syntax
+ const normalVar = pm.variables.get("normal");
+
+ // Multiline syntax
+ const multilineVar = pm.variables
+ .get("multiline");
+
+ // Normal syntax again
+ pm.variables.set("normalSet", "value");
+
+ // Multiline syntax again
+ pm.variables
+ .set("multilineSet", "value");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ // Normal syntax
+ const normalVar = bru.getVar("normal");
+
+ // Multiline syntax
+ const multilineVar = bru.getVar("multiline");
+
+ // Normal syntax again
+ bru.setVar("normalSet", "value");
+
+ // Multiline syntax again
+ bru.setVar("multilineSet", "value");
+ `);
+ });
+
+ it('should handle complex multiline method chaining', () => {
+ const code = `
+ pm
+ .test("Test with chaining", function() {
+ pm
+ .response
+ .to
+ .have
+ .status(200);
+
+ const body = pm
+ .response
+ .json();
+
+ pm
+ .expect(body)
+ .to
+ .have
+ .property('success')
+ .equal(true);
+ });
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('test("Test with chaining", function() {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
+ expect(translatedCode).toContain('const body = res.getBody()');
+ expect(translatedCode).toContain('.property(\'success\')');
+ expect(translatedCode).toContain('.equal(true)');
+ });
+
+ it('should handle a comprehensive script with various multiline formats', () => {
+ const code = `
+ // This comprehensive script tests different multiline styles and whitespace variations
+
+ // Environment variables with different formatting styles
+ const baseUrl = pm.environment.get("baseUrl");
+ const apiKey = pm
+ .environment
+ .get("apiKey");
+ const userId = pm.environment
+ .get("userId");
+
+ // Mix of variable styles
+ pm.variables.set("testId", "test-" + Date.now());
+ pm
+ .variables
+ .set("timestamp", new Date().toISOString());
+
+ // Collection variables with inconsistent spacing
+ pm.collectionVariables
+ .set("lastRun", new Date());
+
+ // Complex conditionals with multiline expressions
+ if (pm
+ .environment
+ .has("apiKey") &&
+ pm.variables.has("testId")) {
+
+ // Testing response with mixed syntax styles
+ pm.test("Response validation", function() {
+ // Normal style
+ pm.response.to.have.status(200);
+
+ // Multiline with different indentation
+ pm
+ .response
+ .to
+ .have
+ .header("content-type");
+
+ pm.response
+ .to.have
+ .jsonBody("success", true);
+
+ // Extreme indentation
+ pm
+ .response
+ .to
+ .not
+ .have
+ .jsonBody("error");
+ });
+
+ // Flow control with mixed styles
+ if (pm.response.code === 401) {
+ pm.execution.setNextRequest(null);
+ } else {
+ pm
+ .execution
+ .setNextRequest("Next API Call");
+ }
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl")');
+ expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey")');
+ expect(translatedCode).toContain('const userId = bru.getEnvVar("userId")');
+
+ // Check variables translations
+ expect(translatedCode).toContain('bru.setVar("testId", "test-" + Date.now())');
+ expect(translatedCode).toContain('bru.setVar("timestamp", new Date().toISOString())');
+
+ // Check collection variables
+ expect(translatedCode).toContain('bru.setVar("lastRun", new Date())');
+
+ // Check complex conditionals
+ expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null &&');
+ expect(translatedCode).toContain('bru.hasVar("testId"))');
+
+ // Check response testing
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("content-type".toLowerCase())');
+
+ // Check flow control
+ expect(translatedCode).toContain('if (res.getStatus() === 401)');
+ expect(translatedCode).toContain('bru.runner.stopExecution()');
+ expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js
new file mode 100644
index 000000000..20e7890a7
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js
@@ -0,0 +1,132 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Postman to PM References Conversion', () => {
+ // Basic conversions
+ it('should convert basic postman references to pm', () => {
+ const code = 'postman.setEnvironmentVariable("key", "value");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('bru.setEnvVar("key", "value");');
+ // The key part is that it should convert postman.* to pm.* internally before
+ // translating to bru.* APIs
+ });
+
+ it('should convert postman variable access to pm', () => {
+ const code = 'const value = postman.getEnvironmentVariable("key");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const value = bru.getEnvVar("key");');
+ });
+
+ it('should handle postman variable assignments', () => {
+ const code = `
+ const envVar = postman.environment.get("apiKey");
+ const baseUrl = postman.environment.get("baseUrl");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const envVar = bru.getEnvVar("apiKey");');
+ expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
+ });
+
+ // More complex patterns
+ it('should handle mixed postman and pm references in the same code', () => {
+ const code = `
+ // Using both postman and pm APIs
+ const apiKey = postman.environment.get("apiKey");
+ const baseUrl = pm.environment.get("baseUrl");
+
+ // Using both formats in a test
+ postman.test("Status code is 200", function() {
+ pm.expect(pm.response.code).to.equal(200);
+ });
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey");');
+ expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
+ expect(translatedCode).toContain('test("Status code is 200", function() {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ });
+
+ it('should handle postman references in object destructuring', () => {
+ const code = `
+ const { environment } = postman;
+ environment.set("key", "value");
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('bru.setEnvVar("key", "value");');
+ });
+
+ // Complex control flows
+ it('should handle postman references in control flow statements', () => {
+ const code = `
+ if (postman.environment.get("isProduction") === "true") {
+ const apiUrl = postman.environment.get("prodUrl");
+ postman.setNextRequest("Production Flow");
+ } else {
+ const apiUrl = postman.environment.get("devUrl");
+ postman.setNextRequest("Development Flow");
+ }
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('if (bru.getEnvVar("isProduction") === "true") {');
+ expect(translatedCode).toContain('const apiUrl = bru.getEnvVar("prodUrl");');
+ expect(translatedCode).toContain('bru.setNextRequest("Production Flow");');
+ expect(translatedCode).toContain('const apiUrl = bru.getEnvVar("devUrl");');
+ expect(translatedCode).toContain('bru.setNextRequest("Development Flow");');
+ });
+
+ // Legacy response handling
+ it('should handle legacy postman response methods', () => {
+ const code = `
+ // Using legacy response handling
+ const responseCode = postman.response.code;
+ const responseBody = postman.response.json();
+
+ // Set environment variables with response data
+ postman.setEnvironmentVariable("lastResponseCode", responseCode);
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const responseCode = res.getStatus();');
+ expect(translatedCode).toContain('const responseBody = res.getBody();');
+ expect(translatedCode).toContain('bru.setEnvVar("lastResponseCode", responseCode);');
+ });
+
+ // Postman in string literals should be untouched
+ it('should not convert postman references in string literals', () => {
+ const code = `
+ console.log("This is a pm script");
+ const message = "We're using pm to test our API";
+ `;
+
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('console.log("This is a pm script");');
+ expect(translatedCode).toContain('const message = "We\'re using pm to test our API";');
+ });
+
+ // Complex example with aliasing
+ it('should handle complex postman reference patterns with aliasing', () => {
+ const code = `
+ // Aliasing the postman object
+ const env = postman.environment;
+ const code = postman.code;
+
+ // Using the alias
+ const apiKey = env.get("apiKey");
+ const userId = env.get("userId");
+
+ // Using alias in tests
+ postman.test("Response is valid", function() {
+ postman.expect(code).to.equal(200);
+ });
+ `;
+
+ const translatedCode = translateCode(code);
+ // Should handle the aliases properly
+ expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey");');
+ expect(translatedCode).toContain('const userId = bru.getEnvVar("userId");');
+ expect(translatedCode).toContain('test("Response is valid", function() {');
+ expect(translatedCode).toContain('expect(code).to.equal(200);');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js
new file mode 100644
index 000000000..a81bc6c72
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js
@@ -0,0 +1,108 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Request Translation', () => {
+ it('should translate pm.request.url', () => {
+ const code = 'const requestUrl = pm.request.url;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const requestUrl = req.getUrl();');
+ });
+
+ it('should translate pm.request.method', () => {
+ const code = 'const method = pm.request.method;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const method = req.getMethod();');
+ });
+
+ it('should translate pm.request.headers', () => {
+ const code = 'const headers = pm.request.headers;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const headers = req.getHeaders();');
+ });
+
+ it('should translate pm.request.body', () => {
+ const code = 'const body = pm.request.body;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const body = req.getBody();');
+ });
+
+ it('should translate pm.response.statusText', () => {
+ const code = 'const statusText = pm.response.statusText;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const statusText = res.statusText;');
+ });
+
+ it('should translate multiple request methods in one block', () => {
+ const code = `
+ const url = pm.request.url;
+ const method = pm.request.method;
+ const headers = pm.request.headers;
+ const body = pm.request.body;
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const url = req.getUrl();
+ const method = req.getMethod();
+ const headers = req.getHeaders();
+ const body = req.getBody();
+ `);
+ });
+
+ it('should handle request and response properties together', () => {
+ const code = `
+ // Get request data
+ const url = pm.request.url;
+ const method = pm.request.method;
+
+ // Get response data
+ const statusCode = pm.response.code;
+ const statusText = pm.response.statusText;
+
+ // Verify expectations
+ pm.test("Request was made correctly", function() {
+ pm.expect(method).to.equal("POST");
+ pm.expect(url).to.include("/api/items");
+ });
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const url = req.getUrl();');
+ expect(translatedCode).toContain('const method = req.getMethod();');
+ expect(translatedCode).toContain('const statusCode = res.getStatus();');
+ expect(translatedCode).toContain('const statusText = res.statusText;');
+ expect(translatedCode).toContain('test("Request was made correctly", function() {');
+ expect(translatedCode).toContain('expect(method).to.equal("POST");');
+ expect(translatedCode).toContain('expect(url).to.include("/api/items");');
+ });
+
+ it('should handle request properties in conditional blocks', () => {
+ const code = `
+ if (pm.request.method === "POST") {
+ console.log("This is a POST request to " + pm.request.url);
+ pm.test("Request has correct content-type", function() {
+ pm.expect(pm.request.headers.has("Content-Type")).to.be.true;
+ });
+ }
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('if (req.getMethod() === "POST") {');
+ expect(translatedCode).toContain('console.log("This is a POST request to " + req.getUrl());');
+ expect(translatedCode).toContain('test("Request has correct content-type", function() {');
+ // Note: The expectation for headers.has might be transformed differently
+ // depending on how complex transformations are handled
+ });
+
+ it('should handle request data extraction and variable setting', () => {
+ const code = `
+ // Extract request data
+ const requestData = pm.request.body;
+ const contentType = pm.request.headers.get("Content-Type");
+
+ // Save for later use
+ pm.variables.set("lastRequestBody", JSON.stringify(requestData));
+ pm.environment.set("lastContentType", contentType);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const requestData = req.getBody();');
+ expect(translatedCode).toContain('bru.setVar("lastRequestBody", JSON.stringify(requestData));');
+ expect(translatedCode).toContain('bru.setEnvVar("lastContentType", contentType);');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js
new file mode 100644
index 000000000..3a1e45dbc
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js
@@ -0,0 +1,556 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Response Translation', () => {
+ // Basic response property tests
+ it('should translate pm.response.json', () => {
+ const code = 'const jsonData = pm.response.json();';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const jsonData = res.getBody();');
+ });
+
+ it('should translate pm.response.code', () => {
+ const code = 'if (pm.response.code === 200) { console.log("Success"); }';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('if (res.getStatus() === 200) { console.log("Success"); }');
+ });
+
+ it('should translate pm.response.text', () => {
+ const code = 'const responseText = pm.response.text();';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('const responseText = JSON.stringify(res.getBody());');
+ });
+
+ it('should translate pm.response.responseTime', () => {
+ const code = 'console.log("Response time:", pm.response.responseTime);';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('console.log("Response time:", res.getResponseTime());');
+ });
+
+ it('should translate pm.response.statusText', () => {
+ const code = 'console.log("Status text:", pm.response.statusText);';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('console.log("Status text:", res.statusText);');
+ });
+
+ it('should translate pm.response.headers', () => {
+ const code = 'console.log("Headers:", pm.response.headers);';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('console.log("Headers:", res.getHeaders());');
+ });
+
+ // Complex response transformations
+ it('should transform pm.response.to.have.status', () => {
+ const code = 'pm.response.to.have.status(201);';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('expect(res.getStatus()).to.equal(201);');
+ });
+
+ it('should transform pm.response.to.have.header with single argument', () => {
+ const code = 'pm.response.to.have.header("Content-Type");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase());');
+ });
+
+ it('should transform multiple pm.response.to.have.header statements', () => {
+ const code = `
+ pm.response.to.have.header("Content-Type", "application/json");
+ pm.response.to.have.header("Cache-Control", "no-cache");
+ `;
+ const translatedCode = translateCode(code);
+
+ // Check for the existence of all four assertions (two pairs)
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Cache-Control".toLowerCase(), "no-cache");');
+ });
+
+ it('should transform pm.response.to.have.header inside control structures', () => {
+ const code = `
+ if (pm.response.code === 200) {
+ pm.response.to.have.header("Content-Type", "application/json");
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ // The assertions should be inside the if block
+ expect(translatedCode).toContain('if (res.getStatus() === 200) {');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
+ });
+
+ it('should transform pm.response.to.have.header with variable parameters', () => {
+ const code = `
+ const headerName = "Content-Type";
+ const expectedValue = "application/json";
+ pm.response.to.have.header(headerName, expectedValue);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const headerName = "Content-Type";');
+ expect(translatedCode).toContain('const expectedValue = "application/json";');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase(), expectedValue);');
+ });
+
+ // Response aliases tests
+ it('should handle response aliases', () => {
+ const code = `
+ const response = pm.response;
+ const status = response.status;
+ const body = response.json();
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const status = res.statusText;
+ const body = res.getBody();
+ `);
+ });
+
+ // Response to.have.status with different formats
+ it('should handle pm.response.to.have.status with different status codes', () => {
+ const code = `
+ // Test different status codes
+ pm.response.to.have.status(200); // OK
+ pm.response.to.have.status(201); // Created
+ pm.response.to.have.status(400); // Bad Request
+ pm.response.to.have.status(404); // Not Found
+ pm.response.to.have.status(500); // Server Error
+
+ // With variables
+ const expectedStatus = 200;
+ pm.response.to.have.status(expectedStatus);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(201);');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(400);');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(404);');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(500);');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(expectedStatus);');
+ });
+
+ // Alias for pm.response.to.have.status
+ it('should handle pm.response.to.have.status alias', () => {
+ const code = `
+ const resp = pm.response;
+ resp.to.have.status(200);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ expect(res.getStatus()).to.equal(200);
+ `);
+ });
+
+ it('should handle pm.response.to.have.header alias', () => {
+ const code = `
+ const resp = pm.response;
+ resp.to.have.header("Content-Type");
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase());
+ `);
+ });
+
+ it('should handle pm.response.to.have.header alias with value check', () => {
+ const code = `
+ const resp = pm.response;
+ resp.to.have.header("Content-Type", "application/json");
+ `;
+ const translatedCode = translateCode(code);
+
+ // Check for both assertions when using an alias
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
+ });
+
+
+ it('should translate response.status', () => {
+ const code = `
+ const resp = pm.response;
+ const statusCode = resp.status;
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const statusCode = res.statusText;
+ `);
+ });
+
+ it('should translate response.body', () => {
+ const code = `
+ const resp = pm.response;
+ const responseBody = resp.json();
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const responseBody = res.getBody();
+ `);
+ });
+
+ it('should translate response.headers', () => {
+ const code = `
+ const resp = pm.response;
+ const headers = resp.headers;
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const headers = res.getHeaders();
+ `);
+ });
+
+ it('should translate pm.response.statusText', () => {
+ const code = `
+ const resp = pm.response;
+ const statusText = resp.statusText;
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const statusText = res.statusText;
+ `);
+ });
+
+ it('should translate multiple response methods in one block', () => {
+ const code = `
+ const resp = pm.response;
+ const statusCode = resp.code;
+ const statusText = resp.statusText;
+ const jsonData = resp.json();
+ const responseText = resp.text();
+ const time = resp.responseTime;
+ resp.to.have.status(200);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ const statusCode = res.getStatus();
+ const statusText = res.statusText;
+ const jsonData = res.getBody();
+ const responseText = JSON.stringify(res.getBody());
+ const time = res.getResponseTime();
+ expect(res.getStatus()).to.equal(200);
+ `);
+ });
+
+ it('should handle accessing nested properties on response objects', () => {
+ const code = `
+ const resp = pm.response;
+ const data = resp.json();
+ if (data && data.user && data.user.id) {
+ pm.environment.set("userId", data.user.id);
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).not.toContain('const resp = pm.response;');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('bru.setEnvVar("userId", data.user.id);');
+ });
+
+ it('should handle all response property methods together', () => {
+ const code = `
+ // All response property methods
+ const statusCode = pm.response.code;
+ const responseBody = pm.response.json();
+ const responseText = pm.response.text();
+ const statusText = pm.response.statusText;
+ const responseTime = pm.response.responseTime;
+
+ pm.test("Response is valid", function() {
+ pm.response.to.have.status(200);
+ pm.expect(responseBody).to.be.an('object');
+ pm.expect(responseTime).to.be.below(1000);
+ pm.expect(statusText).to.equal('OK');
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const statusCode = res.getStatus();');
+ expect(translatedCode).toContain('const responseBody = res.getBody();');
+ expect(translatedCode).toContain('const responseText = JSON.stringify(res.getBody());');
+ expect(translatedCode).toContain('const responseTime = res.getResponseTime();');
+ expect(translatedCode).toContain('const statusText = res.statusText;');
+ expect(translatedCode).toContain('test("Response is valid", function() {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ expect(translatedCode).toContain('expect(responseBody).to.be.an(\'object\');');
+ expect(translatedCode).toContain('expect(responseTime).to.be.below(1000);');
+ expect(translatedCode).toContain('expect(statusText).to.equal(\'OK\');');
+ });
+
+ it('should handle pm objects with array access on response', () => {
+ const code = `
+ const items = pm.response.json().items;
+ for (let i = 0; i < items.length; i++) {
+ pm.collectionVariables.set("item_" + i, items[i].id);
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const items = res.getBody().items;');
+ expect(translatedCode).toContain('bru.setVar("item_" + i, items[i].id);');
+ });
+
+ it('should handle response JSON with optional chaining and nullish coalescing', () => {
+ const code = `
+ const userId = pm.response.json()?.user?.id ?? "anonymous";
+ const items = pm.response.json()?.data?.items || [];
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const userId = res.getBody()?.user?.id ?? "anonymous";');
+ expect(translatedCode).toContain('const items = res.getBody()?.data?.items || [];');
+ });
+
+ it('should handle response headers with different access patterns', () => {
+ // will need to handle get, set methods, bruno does not support this yet
+ const code = `
+ const contentType = pm.response.headers.get('Content-Type');
+ const contentLength = pm.response.headers.get('Content-Length');
+ console.log("contentType", contentType);
+ console.log("contentLength", contentLength);
+
+ pm.test("Headers are correct", function() {
+ pm.response.to.have.header('Content-Type');
+ pm.response.to.have.header('Content-Length');
+ pm.expect(contentType).to.include('application/json');
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ // Check how header access is translated
+ expect(translatedCode).toContain('const contentType = res.getHeader(\'Content-Type\');');
+ expect(translatedCode).toContain('const contentLength = res.getHeader(\'Content-Length\');');
+ expect(translatedCode).toContain('console.log("contentType", contentType);');
+ expect(translatedCode).toContain('console.log("contentLength", contentLength);');
+ expect(translatedCode).not.toContain('pm.test')
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(\'Content-Type\'.toLowerCase())');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(\'Content-Length\'.toLowerCase())');
+ expect(translatedCode).toContain('expect(contentType).to.include(\'application/json\')');
+ });
+
+ it('should transform response data with array destructuring', () => {
+ const code = `
+ const { id, name, items } = pm.response.json();
+ const [first, second] = items;
+ pm.environment.set("userId", id);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const { id, name, items } = res.getBody();');
+ expect(translatedCode).toContain('const [first, second] = items;');
+ expect(translatedCode).toContain('bru.setEnvVar("userId", id);');
+ });
+
+ it('should handle response in complex conditionals', () => {
+ const code = `
+ if (pm.response.code >= 200 && pm.response.code < 300) {
+ if (pm.response.headers.get('Content-Type').includes('application/json')) {
+ const data = pm.response.json();
+
+ if (data.success === true && data.token) {
+ pm.environment.set("authToken", data.token);
+ } else if (data.error) {
+ console.error("API error:", data.error);
+ }
+ }
+ } else if (pm.response.code === 404) {
+ console.log("Resource not found");
+ } else {
+ console.error("Request failed with status:", pm.response.code);
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('if (res.getStatus() >= 200 && res.getStatus() < 300) {');
+ expect(translatedCode).toContain('if (res.getHeader(\'Content-Type\').includes(\'application/json\')) {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('bru.setEnvVar("authToken", data.token);');
+ expect(translatedCode).toContain('} else if (res.getStatus() === 404) {');
+ expect(translatedCode).toContain('console.error("Request failed with status:", res.getStatus());');
+ });
+
+ it('should handle response processing with try-catch', () => {
+ const code = `
+ try {
+ const data = pm.response.json();
+ pm.environment.set("userData", JSON.stringify(data.user));
+ } catch (error) {
+ console.error("Failed to parse response:", error);
+ const text = pm.response.text();
+ pm.environment.set("rawResponse", text);
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('bru.setEnvVar("userData", JSON.stringify(data.user));');
+ expect(translatedCode).toContain('const text = JSON.stringify(res.getBody());');
+ expect(translatedCode).toContain('bru.setEnvVar("rawResponse", text);');
+ });
+
+ it('should handle JSON path style access to response data', () => {
+ const code = `
+ const data = pm.response.json();
+ const userId = data.user.id;
+ const userEmail = data.user.contact.email;
+ const firstItem = data.items[0];
+
+ pm.environment.set("userId", userId);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('const userId = data.user.id;');
+ expect(translatedCode).toContain('const userEmail = data.user.contact.email;');
+ expect(translatedCode).toContain('const firstItem = data.items[0];');
+ expect(translatedCode).toContain('bru.setEnvVar("userId", userId);');
+ });
+
+ it('should handle template literals with response data', () => {
+ const code = `
+ const data = pm.response.json();
+ const welcomeMessage = \`Hello, \${data.user.name}! Your ID is \${data.user.id}.\`;
+
+ pm.environment.set("welcomeMessage", welcomeMessage);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('const welcomeMessage = `Hello, ${data.user.name}! Your ID is ${data.user.id}.`;');
+ expect(translatedCode).toContain('bru.setEnvVar("welcomeMessage", welcomeMessage);');
+ });
+
+ it('should handle response processing in arrow functions', () => {
+ const code = `
+ const processItems = () => {
+ const items = pm.response.json().items;
+ return items.map(item => item.id);
+ };
+
+ const itemIds = processItems();
+ pm.environment.set("itemIds", JSON.stringify(itemIds));
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const items = res.getBody().items;');
+ expect(translatedCode).toContain('return items.map(item => item.id);');
+ expect(translatedCode).toContain('const itemIds = processItems();');
+ expect(translatedCode).toContain('bru.setEnvVar("itemIds", JSON.stringify(itemIds));');
+ });
+
+ it('should handle complex inline operations with response data', () => {
+ const code = `
+ const items = pm.response.json().items;
+ const totalValue = items.reduce((sum, item) => sum + item.price, 0);
+ const highValueItems = items.filter(item => item.price > 100);
+ const itemNames = items.map(item => item.name);
+
+ pm.environment.set("totalValue", totalValue);
+ pm.environment.set("highValueItemCount", highValueItems.length);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const items = res.getBody().items;');
+ expect(translatedCode).toContain('const totalValue = items.reduce((sum, item) => sum + item.price, 0);');
+ expect(translatedCode).toContain('const highValueItems = items.filter(item => item.price > 100);');
+ expect(translatedCode).toContain('const itemNames = items.map(item => item.name);');
+ expect(translatedCode).toContain('bru.setEnvVar("totalValue", totalValue);');
+ expect(translatedCode).toContain('bru.setEnvVar("highValueItemCount", highValueItems.length);');
+ });
+
+ it('should handle complex test structure with pm.response.to.have.header', () => {
+ const code = `
+ pm.test("Response headers validation", function() {
+ pm.response.to.have.header("Content-Type", "application/json");
+ pm.response.to.have.header("Cache-Control");
+
+ const responseTime = pm.response.responseTime;
+ pm.expect(responseTime).to.be.below(1000);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ // Check for test function conversion
+ expect(translatedCode).toContain('test("Response headers validation", function() {');
+
+ // Check for header assertions inside the test callback
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Cache-Control".toLowerCase())');
+
+ // Check that other test assertions are preserved
+ expect(translatedCode).toContain('const responseTime = res.getResponseTime();');
+ expect(translatedCode).toContain('expect(responseTime).to.be.below(1000);');
+ });
+
+ it('should handle dynamic header names in pm.response.to.have.header', () => {
+ const code = `
+ function checkHeaderPresent(headerName) {
+ pm.response.to.have.header(headerName);
+ }
+
+ function validateHeader(headerName, expectedValue) {
+ pm.response.to.have.header(headerName, expectedValue);
+ }
+
+ checkHeaderPresent("Authorization");
+ validateHeader("Content-Type", "application/json");
+ `;
+ const translatedCode = translateCode(code);
+
+ // Check function transformations
+ expect(translatedCode).toContain('function checkHeaderPresent(headerName) {');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase())');
+
+ expect(translatedCode).toContain('function validateHeader(headerName, expectedValue) {');
+ expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase(), expectedValue);');
+
+ // Check function calls
+ expect(translatedCode).toContain('checkHeaderPresent("Authorization");');
+ expect(translatedCode).toContain('validateHeader("Content-Type", "application/json");');
+ });
+
+ it('should transform pm.response.to.have.body with string literal', () => {
+ const code = 'pm.response.to.have.body("Expected response body");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('expect(res.getBody()).to.equal("Expected response body");');
+ });
+
+ it('should transform pm.response.to.have.body with variable parameter', () => {
+ const code = `
+ const expectedBody = {"status": "success", "data": [1, 2, 3]};
+ pm.response.to.have.body(expectedBody);
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toContain('const expectedBody = {"status": "success", "data": [1, 2, 3]};');
+ expect(translatedCode).toContain('expect(res.getBody()).to.equal(expectedBody);');
+ });
+
+ it('should transform pm.response.to.have.body with JSON object', () => {
+ const code = `pm.response.to.have.body({"status": "success", "message": "Operation completed"});`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('expect(res.getBody()).to.equal({"status": "success", "message": "Operation completed"});');
+ });
+
+ it('should transform pm.response.to.have.body inside test function', () => {
+ const code = `
+ pm.test("Response body validation", function() {
+ const expectedResponse = {"result": true};
+ pm.response.to.have.body(expectedResponse);
+ });
+ `;
+ const translatedCode = translateCode(code);
+ const expectedOutput = `
+ test("Response body validation", function() {
+ const expectedResponse = {"result": true};
+ expect(res.getBody()).to.equal(expectedResponse);
+ });
+ `
+ expect(translatedCode).toBe(expectedOutput);
+ });
+
+ it('should transform pm.response.to.have.body with response alias', () => {
+ const code = `
+ const resp = pm.response;
+ resp.to.have.body({"status": "ok"});
+ `;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ expect(res.getBody()).to.equal({"status": "ok"});
+ `);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js
new file mode 100644
index 000000000..9ed5ed700
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js
@@ -0,0 +1,51 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Scoped Variables', () => {
+ it.skip('should handle scoped variables correctly', () => {
+ const code = `
+ const response = pm.response;
+ const status = response.status;
+
+ function test() {
+ const response = delta.response;
+ const status = response.status;
+ console.log(status);
+ }
+ `
+ const result = translateCode(code);
+ console.log(result);
+ expect(result).toBe(`
+ const status = res.statusText;
+
+ function test() {
+ const response = delta.response;
+ const status = response.status;
+ console.log(status);
+ }
+ `)
+ })
+
+ it.skip('should handle scoped variables correctly', () => {
+ const code = `
+ const response = delta.response;
+ const status = response.status;
+
+ function test() {
+ const response = pm.response;
+ const status = response.status;
+ console.log(status);
+ }
+ `
+ const result = translateCode(code);
+ console.log(result);
+ expect(result).toBe(`
+ const response = delta.response;
+ const status = response.status;
+
+ function test() {
+ const status = res.statusText;
+ console.log(status);
+ }
+ `)
+ })
+})
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js
new file mode 100644
index 000000000..fc3988f1f
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js
@@ -0,0 +1,399 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Testing Framework Translation', () => {
+ // Basic testing framework translations
+ it('should translate pm.test', () => {
+ const code = 'pm.test("Status code is 200", function() { pm.response.to.have.status(200); });';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });');
+ });
+
+ it('should translate pm.expect', () => {
+ const code = 'pm.expect(jsonData.success).to.be.true;';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('expect(jsonData.success).to.be.true;');
+ });
+
+ it('should translate pm.expect.fail', () => {
+ const code = 'if (!isValid) pm.expect.fail("Data is invalid");';
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe('if (!isValid) expect.fail("Data is invalid");');
+ });
+
+ // Tests with response assertions
+ it('should translate pm.response.to.have.status in tests', () => {
+ const code = `
+ pm.test("Check environment and call successful", function () {
+ pm.expect(pm.environment.name).to.equal("ENVIRONMENT_NAME");
+ pm.response.to.have.status(200);
+ });`;
+ const translatedCode = translateCode(code);
+ expect(translatedCode).toBe(`
+ test("Check environment and call successful", function () {
+ expect(bru.getEnvName()).to.equal("ENVIRONMENT_NAME");
+ expect(res.getStatus()).to.equal(200);
+ });`);
+ });
+
+ // Test aliases
+ it('should handle test aliases', () => {
+ const code = `
+ const { test, expect } = pm;
+
+ test("Status code is 200", function () {
+ expect(pm.response.code).to.equal(200);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).not.toContain('const { test, expect } = pm');
+ expect(translatedCode).toContain('test("Status code is 200", function () {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ });
+
+ // Tests inside different code structures
+ it('should translate pm commands inside tests with nested functions', () => {
+ const code = `
+ pm.test("Auth flow works", function() {
+ const response = pm.response.json();
+ pm.expect(response.authenticated).to.be.true;
+ pm.environment.set("userId", response.user.id);
+ pm.collectionVariables.set("sessionId", response.session.id);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Auth flow works", function() {');
+ expect(translatedCode).toContain('const response = res.getBody();');
+ expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
+ expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
+ expect(translatedCode).toContain('bru.setVar("sessionId", response.session.id);');
+ });
+
+ it('should translate pm.test with arrow functions', () => {
+ const code = `
+ pm.test("Status code is 200", () => {
+ pm.expect(pm.response.code).to.eql(200);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Status code is 200", () => {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.eql(200);');
+ });
+
+ it('should handle multiple test assertions in one function', () => {
+ const code = `
+ pm.test("The response has all properties", () => {
+ const responseJson = pm.response.json();
+ pm.expect(responseJson.type).to.eql('vip');
+ pm.expect(responseJson.name).to.be.a('string');
+ pm.expect(responseJson.id).to.have.lengthOf(1);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("The response has all properties", () => {');
+ expect(translatedCode).toContain('const responseJson = res.getBody();');
+ expect(translatedCode).toContain('expect(responseJson.type).to.eql(\'vip\');');
+ expect(translatedCode).toContain('expect(responseJson.name).to.be.a(\'string\');');
+ expect(translatedCode).toContain('expect(responseJson.id).to.have.lengthOf(1);');
+ });
+
+ // Test with aliased variables
+ it('should translate aliases within test functions', () => {
+ const code = `
+ const tempRes = pm.response;
+ const tempTest = pm.test;
+ const tempExpect = pm.expect;
+
+ tempTest("Status code is 200", function() {
+ tempExpect(tempRes.code).to.equal(200);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).not.toContain('const tempRes = pm.response;');
+ expect(translatedCode).not.toContain('const tempTest = pm.test;');
+ expect(translatedCode).not.toContain('const tempExpect = pm.expect;');
+ expect(translatedCode).toContain('test("Status code is 200", function() {');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ });
+
+ // Additional robust tests for testing framework
+ it('should handle nested test functions', () => {
+ const code = `
+ pm.test("Main test group", function() {
+ const responseJson = pm.response.json();
+
+ pm.test("User data validation", function() {
+ pm.expect(responseJson.user).to.be.an('object');
+ pm.expect(responseJson.user.id).to.be.a('string');
+ });
+
+ pm.test("Settings validation", function() {
+ pm.expect(responseJson.settings).to.be.an('object');
+ pm.expect(responseJson.settings.notifications).to.be.a('boolean');
+ });
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Main test group", function() {');
+ expect(translatedCode).toContain('const responseJson = res.getBody();');
+ expect(translatedCode).toContain('test("User data validation", function() {');
+ expect(translatedCode).toContain('expect(responseJson.user).to.be.an(\'object\');');
+ expect(translatedCode).toContain('test("Settings validation", function() {');
+ expect(translatedCode).toContain('expect(responseJson.settings.notifications).to.be.a(\'boolean\');');
+ });
+
+ it('should handle test with dynamic test names', () => {
+ const code = `
+ const endpoint = pm.variables.get("currentEndpoint");
+
+ pm.test(\`\${endpoint} returns correct data\`, function() {
+ const responseJson = pm.response.json();
+ pm.expect(responseJson).to.be.an('object');
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const endpoint = bru.getVar("currentEndpoint");');
+ expect(translatedCode).toContain('test(`${endpoint} returns correct data`, function() {');
+ expect(translatedCode).toContain('const responseJson = res.getBody();');
+ expect(translatedCode).toContain('expect(responseJson).to.be.an(\'object\');');
+ });
+
+ it('should handle test with conditional execution', () => {
+ const code = `
+ const responseJson = pm.response.json();
+
+ if (responseJson.type === 'user') {
+ pm.test("User validation", function() {
+ pm.expect(responseJson.name).to.be.a('string');
+ pm.expect(responseJson.email).to.be.a('string');
+ });
+ } else if (responseJson.type === 'admin') {
+ pm.test("Admin validation", function() {
+ pm.expect(responseJson.accessLevel).to.be.above(5);
+ pm.expect(responseJson.permissions).to.be.an('array');
+ });
+ }
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const responseJson = res.getBody();');
+ expect(translatedCode).toContain('if (responseJson.type === \'user\') {');
+ expect(translatedCode).toContain('test("User validation", function() {');
+ expect(translatedCode).toContain('expect(responseJson.name).to.be.a(\'string\');');
+ expect(translatedCode).toContain('} else if (responseJson.type === \'admin\') {');
+ expect(translatedCode).toContain('test("Admin validation", function() {');
+ expect(translatedCode).toContain('expect(responseJson.accessLevel).to.be.above(5);');
+ });
+
+ it('should handle assertions with logical operators', () => {
+ const code = `
+ pm.test("Response has valid structure", function() {
+ const data = pm.response.json();
+
+ pm.expect(data.id && data.name).to.be.ok;
+ pm.expect(data.active || data.pending).to.be.true;
+ pm.expect(!data.deleted).to.be.true;
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Response has valid structure", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('expect(data.id && data.name).to.be.ok;');
+ expect(translatedCode).toContain('expect(data.active || data.pending).to.be.true;');
+ expect(translatedCode).toContain('expect(!data.deleted).to.be.true;');
+ });
+
+ it('should handle array and object assertions', () => {
+ const code = `
+ pm.test("Array and object validations", function() {
+ const data = pm.response.json();
+
+ // Array validations
+ pm.expect(data.items).to.be.an('array');
+ pm.expect(data.items).to.have.lengthOf.at.least(1);
+ pm.expect(data.items[0]).to.have.property('id');
+
+ // Object validations
+ pm.expect(data.user).to.be.an('object');
+ pm.expect(data.user).to.have.all.keys('id', 'name', 'email');
+ pm.expect(data.user).to.include({active: true});
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Array and object validations", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('expect(data.items).to.be.an(\'array\');');
+ expect(translatedCode).toContain('expect(data.items).to.have.lengthOf.at.least(1);');
+ expect(translatedCode).toContain('expect(data.items[0]).to.have.property(\'id\');');
+ expect(translatedCode).toContain('expect(data.user).to.be.an(\'object\');');
+ expect(translatedCode).toContain('expect(data.user).to.have.all.keys(\'id\', \'name\', \'email\');');
+ expect(translatedCode).toContain('expect(data.user).to.include({active: true});');
+ });
+
+ it('should handle chai assertions with deep equality', () => {
+ const code = `
+ pm.test("Deep equality checks", function() {
+ const data = pm.response.json();
+
+ pm.expect(data.config).to.deep.equal({
+ version: "1.0",
+ active: true,
+ features: ["search", "export"]
+ });
+
+ pm.expect(data.tags).to.have.members(['api', 'test']);
+ pm.expect(data.meta).to.deep.include({format: 'json'});
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Deep equality checks", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('expect(data.config).to.deep.equal({');
+ expect(translatedCode).toContain('version: "1.0",');
+ expect(translatedCode).toContain('active: true,');
+ expect(translatedCode).toContain('features: ["search", "export"]');
+ expect(translatedCode).toContain('expect(data.tags).to.have.members([\'api\', \'test\']);');
+ expect(translatedCode).toContain('expect(data.meta).to.deep.include({format: \'json\'});');
+ });
+
+ it('should handle chai assertions with string comparisons', () => {
+ const code = `
+ pm.test("String validations", function() {
+ const data = pm.response.json();
+
+ pm.expect(data.id).to.be.a('string');
+ pm.expect(data.name).to.match(/^[A-Za-z\\s]+$/);
+ pm.expect(data.description).to.include('API');
+ pm.expect(data.url).to.have.string('api/v1');
+ pm.expect(data.code).to.have.lengthOf(8);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("String validations", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('expect(data.id).to.be.a(\'string\');');
+ expect(translatedCode).toContain('expect(data.name).to.match(/^[A-Za-z\\s]+$/);');
+ expect(translatedCode).toContain('expect(data.description).to.include(\'API\');');
+ expect(translatedCode).toContain('expect(data.url).to.have.string(\'api/v1\');');
+ expect(translatedCode).toContain('expect(data.code).to.have.lengthOf(8);');
+ });
+
+ it('should handle assertions with numeric comparisons', () => {
+ const code = `
+ pm.test("Numeric validations", function() {
+ const data = pm.response.json();
+
+ pm.expect(data.count).to.be.a('number');
+ pm.expect(data.count).to.be.above(0);
+ pm.expect(data.price).to.be.within(10, 100);
+ pm.expect(data.discount).to.be.at.most(25);
+ pm.expect(data.quantity * data.price).to.equal(data.total);
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Numeric validations", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('expect(data.count).to.be.a(\'number\');');
+ expect(translatedCode).toContain('expect(data.count).to.be.above(0);');
+ expect(translatedCode).toContain('expect(data.price).to.be.within(10, 100);');
+ expect(translatedCode).toContain('expect(data.discount).to.be.at.most(25);');
+ expect(translatedCode).toContain('expect(data.quantity * data.price).to.equal(data.total);');
+ });
+
+ it('should handle pm.expect.fail with conditions', () => {
+ const code = `
+ pm.test("Validate critical fields", function() {
+ const data = pm.response.json();
+
+ if (!data.id) {
+ pm.expect.fail("Missing ID field");
+ }
+
+ if (data.status !== 'active' && data.status !== 'pending') {
+ pm.expect.fail("Invalid status: " + data.status);
+ }
+
+ // Continue with normal assertions
+ pm.expect(data.name).to.be.a('string');
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('test("Validate critical fields", function() {');
+ expect(translatedCode).toContain('const data = res.getBody();');
+ expect(translatedCode).toContain('if (!data.id) {');
+ expect(translatedCode).toContain('expect.fail("Missing ID field");');
+ expect(translatedCode).toContain('if (data.status !== \'active\' && data.status !== \'pending\') {');
+ expect(translatedCode).toContain('expect.fail("Invalid status: " + data.status);');
+ expect(translatedCode).toContain('expect(data.name).to.be.a(\'string\');');
+ });
+
+ it('should handle complex test compositions', () => {
+ const code = `
+ // Helper function
+ function validateUserObject(user) {
+ pm.expect(user).to.be.an('object');
+ pm.expect(user.id).to.be.a('string');
+ pm.expect(user.name).to.be.a('string');
+ return user.id && user.name;
+ }
+
+ pm.test("Response validation", function() {
+ const response = pm.response.json();
+ const validUsers = [];
+
+ // Test status code
+ pm.response.to.have.status(200);
+
+ // Test main user
+ if (response.user) {
+ const isValid = validateUserObject(response.user);
+ if (isValid) {
+ validUsers.push(response.user);
+ }
+ }
+
+ // Test related users
+ if (response.relatedUsers && Array.isArray(response.relatedUsers)) {
+ pm.test("Related users validation", function() {
+ response.relatedUsers.forEach((user, index) => {
+ pm.test(\`User at index \${index}\`, function() {
+ const isValid = validateUserObject(user);
+ if (isValid) {
+ validUsers.push(user);
+ }
+ });
+ });
+ });
+ }
+
+ // Set the valid users for later use
+ if (validUsers.length > 0) {
+ pm.environment.set("validUsers", JSON.stringify(validUsers));
+ }
+ });
+ `;
+ const translatedCode = translateCode(code);
+
+ // Test key transformations
+ expect(translatedCode).toContain('function validateUserObject(user) {');
+ expect(translatedCode).toContain('expect(user).to.be.an(\'object\');');
+ expect(translatedCode).toContain('test("Response validation", function() {');
+ expect(translatedCode).toContain('const response = res.getBody();');
+ expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
+ expect(translatedCode).toContain('test("Related users validation", function() {');
+ expect(translatedCode).toContain('test(`User at index ${index}`, function() {');
+ expect(translatedCode).toContain('bru.setEnvVar("validUsers", JSON.stringify(validUsers));');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js
new file mode 100644
index 000000000..3c700000e
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js
@@ -0,0 +1,91 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Variable Chaining Resolution', () => {
+ test('should resolve a simple variable chain (variable pointing to another variable)', () => {
+ const code = `
+ const original = pm.response;
+ const alias = original;
+ const data = alias.json();
+ `;
+
+ const translatedCode = translateCode(code);
+
+ // Check that alias.json() was properly resolved to res.getBody()
+ expect(translatedCode).toContain('const data = res.getBody();');
+ // The original variable declarations should be removed
+ expect(translatedCode).not.toContain('const original =');
+ expect(translatedCode).not.toContain('const alias =');
+ });
+
+ test('should handle mixed variable references correctly', () => {
+ const code = `
+ const respVar = pm.response;
+ const envVar = pm.environment;
+ const respAlias = respVar;
+
+ // These should be replaced
+ const statusCode = respAlias.code;
+ const envValue = envVar.get("key");
+
+ // This should not be replaced
+ const unrelatedVar = "some value";
+ `;
+
+ const translatedCode = translateCode(code);
+
+ // Check correct replacements
+ expect(translatedCode).not.toContain('const respVar');
+ expect(translatedCode).not.toContain('const envVar');
+ expect(translatedCode).toContain('const statusCode = res.getStatus();');
+ expect(translatedCode).toContain('const envValue = bru.getEnvVar("key");');
+
+ // Check that unrelated variables are preserved
+ expect(translatedCode).toContain('const unrelatedVar = "some value";');
+ });
+
+ /**
+ * This test verifies that when multiple variables are declared in a single statement,
+ * only the ones referencing Postman objects are removed and the others are preserved.
+ *
+ * For example, in a statement like:
+ * const response = pm.response, counter = 5, helper = "test";
+ *
+ * Only 'response' should be removed, resulting in:
+ * const counter = 5, helper = "test";
+ */
+ test('should handle multiple variables in one declaration statement', () => {
+ const code = `
+ // Multiple variables in one declaration, with a mix of Postman objects and regular variables
+ const response = pm.response, counter = 5, helper = "test";
+
+ // Using both the Postman reference (should be replaced) and regular values (should be preserved)
+ const statusCode = response.code;
+ console.log("Counter value:", counter);
+ console.log("Helper string:", helper);
+
+ // Another example with different Postman object
+ let env = pm.environment, timeout = 1000, isValid = true;
+ const baseUrl = env.get("baseUrl");
+ `;
+
+ const translatedCode = translateCode(code);
+
+ // Postman references should be replaced
+ expect(translatedCode).not.toContain('response = pm.response');
+ expect(translatedCode).not.toContain('env = pm.environment');
+
+ // Regular variables should be preserved
+ expect(translatedCode).toContain('const counter = 5');
+ expect(translatedCode).toContain('helper = "test"');
+ expect(translatedCode).toContain('timeout = 1000');
+ expect(translatedCode).toContain('isValid = true');
+
+ // References to Postman objects should be properly translated
+ expect(translatedCode).toContain('const statusCode = res.getStatus();');
+ expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
+
+ // Console logs with regular variables should be preserved
+ expect(translatedCode).toContain('console.log("Counter value:", counter);');
+ expect(translatedCode).toContain('console.log("Helper string:", helper);');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js
new file mode 100644
index 000000000..fe0f80593
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js
@@ -0,0 +1,207 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator';
+
+describe('Variables Translation', () => {
+ // Regular variables tests
+ it('should translate pm.variables.get', () => {
+ const code = 'pm.variables.get("test");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.getVar("test");');
+ });
+
+ it('should translate pm.variables.set', () => {
+ const code = 'pm.variables.set("test", "value");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.setVar("test", "value");');
+ });
+
+ it('should translate pm.variables.has', () => {
+ const code = 'pm.variables.has("userId");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.hasVar("userId");');
+ });
+
+ it('should translate pm.variables.replaceIn', () => {
+ const code = 'pm.variables.replaceIn("Hello {{name}}");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.interpolate("Hello {{name}}");');
+ });
+
+ it('should translate pm.variables.replaceIn with variables and expressions', () => {
+ const code = 'const greeting = pm.variables.replaceIn("Hello {{name}}, your user id is {{userId}}");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('const greeting = bru.interpolate("Hello {{name}}, your user id is {{userId}}");');
+ });
+
+ it('should translate pm.variables.replaceIn within complex expressions', () => {
+ const code = 'const url = baseUrl + pm.variables.replaceIn("/users/{{userId}}/profile");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('const url = baseUrl + bru.interpolate("/users/{{userId}}/profile");');
+ });
+
+ it('should translate pm.variables.replaceIn with multiple nested variable references', () => {
+ const code = 'const template = pm.variables.replaceIn("{{prefix}}-{{env}}-{{suffix}}");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('const template = bru.interpolate("{{prefix}}-{{env}}-{{suffix}}");');
+ });
+
+ it('should translate aliased variables.replaceIn', () => {
+ const code = `
+ const variables = pm.variables;
+ const message = variables.replaceIn("Welcome, {{username}}!");
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ const message = bru.interpolate("Welcome, {{username}}!");
+ `);
+ });
+
+ // Collection variables tests
+ it('should translate pm.collectionVariables.get', () => {
+ const code = 'pm.collectionVariables.get("apiUrl");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.getVar("apiUrl");');
+ });
+
+ it('should translate pm.collectionVariables.set', () => {
+ const code = 'pm.collectionVariables.set("token", jsonData.token);';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.setVar("token", jsonData.token);');
+ });
+
+ it('should translate pm.collectionVariables.has', () => {
+ const code = 'pm.collectionVariables.has("authToken");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.hasVar("authToken");');
+ });
+
+ it('should translate pm.collectionVariables.unset', () => {
+ const code = 'pm.collectionVariables.unset("tempVar");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.deleteVar("tempVar");');
+ });
+
+ it('should handle pm.globals.get', () => {
+ const code = 'pm.globals.get("test");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.getGlobalEnvVar("test");');
+ });
+
+ it('should handle pm.globals.set', () => {
+ const code = 'pm.globals.set("test", "value");';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.setGlobalEnvVar("test", "value");');
+ });
+
+ // Alias tests for variables
+ it('should handle variables aliases', () => {
+ const code = `
+ const vars = pm.variables;
+ const has = vars.has("test");
+ const set = vars.set("test", "value");
+ const get = vars.get("test");
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ const has = bru.hasVar("test");
+ const set = bru.setVar("test", "value");
+ const get = bru.getVar("test");
+ `);
+ });
+
+ // Alias tests for collection variables
+ it('should handle collection variables aliases', () => {
+ const code = `
+ const collVars = pm.collectionVariables;
+ const has = collVars.has("test");
+ const set = collVars.set("test", "value");
+ const get = collVars.get("test");
+ const unset = collVars.unset("test");
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ const has = bru.hasVar("test");
+ const set = bru.setVar("test", "value");
+ const get = bru.getVar("test");
+ const unset = bru.deleteVar("test");
+ `);
+ });
+
+ it('should handle pm.globals aliases', () => {
+ const code = `
+ const globals = pm.globals;
+ const get = globals.get("test");
+ const set = globals.set("test", "value");
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe(`
+ const get = bru.getGlobalEnvVar("test");
+ const set = bru.setGlobalEnvVar("test", "value");
+ `);
+ })
+
+ // Combined tests
+ it('should handle conditional expressions with variable calls', () => {
+ const code = 'const userStatus = pm.variables.has("userId") ? "logged-in" : "guest";';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('const userStatus = bru.hasVar("userId") ? "logged-in" : "guest";');
+ });
+
+ it('should handle all variable methods together', () => {
+ const code = `
+ // All variable methods
+ const hasUserId = pm.variables.has("userId");
+ const userId = pm.variables.get("userId");
+ pm.variables.set("requestTime", new Date().toISOString());
+
+ console.log(\`Has userId: \${hasUserId}, User ID: \${userId}\`);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const hasUserId = bru.hasVar("userId");');
+ expect(translatedCode).toContain('const userId = bru.getVar("userId");');
+ expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
+ });
+
+ it('should handle all collection variable methods together', () => {
+ const code = `
+ // All collection variable methods
+ const hasApiUrl = pm.collectionVariables.has("apiUrl");
+ const apiUrl = pm.collectionVariables.get("apiUrl");
+ pm.collectionVariables.set("requestTime", new Date().toISOString());
+ pm.collectionVariables.unset("tempVar");
+
+ console.log(\`Has API URL: \${hasApiUrl}, API URL: \${apiUrl}\`);
+ `;
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toContain('const hasApiUrl = bru.hasVar("apiUrl");');
+ expect(translatedCode).toContain('const apiUrl = bru.getVar("apiUrl");');
+ expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
+ expect(translatedCode).toContain('bru.deleteVar("tempVar");');
+ });
+
+ it('should handle more complex nested expressions with variables', () => {
+ const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
+ const translatedCode = translateCode(code);
+
+ expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js b/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js
new file mode 100644
index 000000000..f5dd69f09
--- /dev/null
+++ b/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js
@@ -0,0 +1,53 @@
+import { describe, it, expect } from '@jest/globals';
+import { getMemberExpressionString } from '../../src/utils/jscode-shift-translator';
+const j = require('jscodeshift');
+
+describe('getMemberExpressionString', () => {
+ it('should correctly convert simple member expressions to strings', () => {
+ // Create a simple member expression: pm.environment.get
+ const memberExpr = j.memberExpression(
+ j.memberExpression(
+ j.identifier('pm'),
+ j.identifier('environment')
+ ),
+ j.identifier('get')
+ );
+
+ const result = getMemberExpressionString(memberExpr);
+ expect(result).toBe('pm.environment.get');
+ });
+
+ it('should handle computed properties with string literals', () => {
+ // Create a computed member expression: pm["environment"]["get"]
+ const memberExpr = j.memberExpression(
+ j.memberExpression(
+ j.identifier('pm'),
+ j.literal('environment'),
+ true // computed
+ ),
+ j.literal('get'),
+ true // computed
+ );
+
+ const result = getMemberExpressionString(memberExpr);
+ expect(result).toBe('pm.environment.get');
+ });
+
+ it('should mark non-string computed properties as [computed]', () => {
+ // Create a computed member expression with variable: obj[varName]
+ const memberExpr = j.memberExpression(
+ j.identifier('obj'),
+ j.identifier('varName'),
+ true // computed
+ );
+
+ const result = getMemberExpressionString(memberExpr);
+ expect(result).toBe('obj.[computed]');
+ });
+
+ it('should handle basic identifiers', () => {
+ const identifier = j.identifier('pm');
+ const result = getMemberExpressionString(identifier);
+ expect(result).toBe('pm');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-converters/tsconfig.json b/packages/bruno-converters/tsconfig.json
new file mode 100644
index 000000000..57a8bcc74
--- /dev/null
+++ b/packages/bruno-converters/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES6",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "jsx": "react",
+ "module": "ESNext",
+ "declaration": true,
+ "declarationDir": "types",
+ "sourceMap": true,
+ "outDir": "dist",
+ "moduleResolution": "node",
+ "emitDeclarationOnly": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "exclude": ["dist", "node_modules", "tests"]
+}
diff --git a/packages/bruno-converters/types/common.d.ts b/packages/bruno-converters/types/common.d.ts
new file mode 100644
index 000000000..2e7608adb
--- /dev/null
+++ b/packages/bruno-converters/types/common.d.ts
@@ -0,0 +1,6 @@
+export declare const uuid: () => string;
+export declare const normalizeFileName: (name: string) => string;
+export declare const validateSchema: (collection?: {}) => Promise;
+export declare const updateUidsInCollection: (_collection: any) => any;
+export declare const transformItemsInCollection: (collection: any) => any;
+export declare const hydrateSeqInCollection: (collection: any) => any;
diff --git a/packages/bruno-docs/package.json b/packages/bruno-docs/package.json
index fc144d697..77aa04c0f 100644
--- a/packages/bruno-docs/package.json
+++ b/packages/bruno-docs/package.json
@@ -8,4 +8,4 @@
],
"dependencies": {
}
-}
+}
\ No newline at end of file
diff --git a/packages/bruno-electron/electron-builder-config.js b/packages/bruno-electron/electron-builder-config.js
index 1b75e4d13..2ac21d0bf 100644
--- a/packages/bruno-electron/electron-builder-config.js
+++ b/packages/bruno-electron/electron-builder-config.js
@@ -3,7 +3,7 @@ require('dotenv').config({ path: process.env.DOTENV_PATH });
const config = {
appId: 'com.usebruno.app',
productName: 'Bruno',
- electronVersion: '21.1.1',
+ electronVersion: '33.2.1',
directories: {
buildResources: 'resources',
output: 'out'
@@ -36,9 +36,22 @@ const config = {
},
win: {
artifactName: '${name}_${version}_${arch}_win.${ext}',
- icon: 'resources/icons/png',
- certificateFile: `${process.env.WIN_CERT_FILEPATH}`,
- certificatePassword: `${process.env.WIN_CERT_PASSWORD}`
+ icon: 'resources/icons/win/icon.ico',
+ target: [
+ {
+ target: 'nsis',
+ arch: ['x64']
+ }
+ ],
+ sign: null,
+ publisherName: 'Bruno Software Inc'
+ },
+ nsis: {
+ oneClick: false,
+ allowToChangeInstallationDirectory: true,
+ allowElevation: true,
+ createDesktopShortcut: true,
+ createStartMenuShortcut: true
}
};
diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json
index 5d4714123..7a6d51382 100644
--- a/packages/bruno-electron/package.json
+++ b/packages/bruno-electron/package.json
@@ -1,14 +1,19 @@
{
- "version": "v1.18.0",
+ "version": "2.0.0",
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/usebruno/bruno.git"
+ },
"private": true,
"main": "src/index.js",
"author": "Anoop M D (https://helloanoop.com/)",
"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",
@@ -16,17 +21,28 @@
"dist:rpm": "electron-builder --linux rpm --config electron-builder-config.js",
"dist:snap": "electron-builder --linux snap --config electron-builder-config.js",
"pack": "electron-builder --dir",
- "test": "jest"
+ "test": "node --experimental-vm-modules $(npx which jest)"
+ },
+ "jest": {
+ "modulePaths": [
+ "node_modules"
+ ]
},
"dependencies": {
- "@aws-sdk/credential-providers": "3.525.0",
+ "@aws-sdk/credential-providers": "3.750.0",
+ "@faker-js/faker": "^9.5.1",
"@usebruno/common": "0.1.0",
+ "@usebruno/converters": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
+ "@usebruno/node-machine-id": "^2.0.0",
"@usebruno/schema": "0.7.0",
+ "@usebruno/vm2": "^3.9.13",
+ "@usebruno/requests": "^0.1.0",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
- "axios": "^1.5.1",
+ "axios": "^1.8.3",
+ "axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
"content-disposition": "^0.5.4",
@@ -41,27 +57,24 @@
"graphql": "^16.6.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
+ "iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.0",
- "json-bigint": "^1.0.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
- "mustache": "^4.2.0",
- "nanoid": "3.3.4",
- "node-machine-id": "^1.1.12",
+ "nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0",
- "vm2": "^3.9.13",
"yup": "^0.32.11"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"devDependencies": {
- "electron": "21.1.1",
- "electron-builder": "23.0.2",
- "electron-icon-maker": "^0.0.5"
+ "electron": "33.2.1",
+ "electron-builder": "25.1.8",
+ "electron-devtools-installer": "^4.0.0"
}
}
diff --git a/packages/bruno-electron/src/app/about-bruno.js b/packages/bruno-electron/src/app/about-bruno.js
new file mode 100644
index 000000000..484a062be
--- /dev/null
+++ b/packages/bruno-electron/src/app/about-bruno.js
@@ -0,0 +1,176 @@
+module.exports = function aboutBruno({version}) {
+ return `
+
+
+
+
+
+ About Bruno
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bruno ${version}
+
+
+
+ `;
+};
\ No newline at end of file
diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js
index 5c9889e13..a6b7a178c 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
@@ -45,9 +45,8 @@ const openCollectionDialog = async (win, watcher) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
});
-
if (filePaths && filePaths[0]) {
- const resolvedPath = normalizeAndResolvePath(filePaths[0]);
+ const resolvedPath = path.resolve(filePaths[0]);
if (isDirectory(resolvedPath)) {
openCollection(win, watcher, resolvedPath);
} else {
@@ -59,7 +58,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 +69,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/menu-template.js b/packages/bruno-electron/src/app/menu-template.js
index e662336ae..f2b4c82aa 100644
--- a/packages/bruno-electron/src/app/menu-template.js
+++ b/packages/bruno-electron/src/app/menu-template.js
@@ -1,7 +1,8 @@
const { ipcMain } = require('electron');
const os = require('os');
-const openAboutWindow = require('about-window').default;
-const { join } = require('path');
+const { BrowserWindow } = require('electron');
+const { version } = require('../../package.json');
+const aboutBruno = require('./about-bruno');
const template = [
{
@@ -77,14 +78,16 @@ const template = [
submenu: [
{
label: 'About Bruno',
- click: () =>
- openAboutWindow({
- product_name: 'Bruno',
- icon_path: join(__dirname, '../about/256x256.png'),
- css_path: join(__dirname, '../about/about.css'),
- homepage: 'https://www.usebruno.com/',
- package_json_dir: join(__dirname, '../..')
- })
+ click: () => {
+ const aboutWindow = new BrowserWindow({
+ width: 350,
+ height: 250,
+ webPreferences: {
+ nodeIntegration: true,
+ },
+ });
+ aboutWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(aboutBruno({version}))}`);
+ }
},
{ label: 'Documentation', click: () => ipcMain.emit('main:open-docs') }
]
diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js
index 441bba3b2..a2f91d953 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 } = require('../utils/filesystem');
-const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru');
+const { hasBruExtension, isWSLPath, normalizeAndResolvePath, sizeInMB } = require('../utils/filesystem');
+const { bruToEnvJson, bruToJson, bruToJsonViaWorker, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { uuid } = require('../utils/common');
@@ -12,6 +12,10 @@ const { decryptString } = require('../utils/encryption');
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();
@@ -40,32 +44,9 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
-
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)) {
@@ -202,7 +183,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
const payload = {
collectionUid,
processEnvVariables: {
- ...process.env,
...jsonData
}
};
@@ -229,7 +209,31 @@ 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);
+ return;
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+ }
+
+ if (path.basename(pathname) === 'folder.bru') {
+ const file = {
+ meta: {
+ collectionUid,
+ pathname,
+ name: path.basename(pathname),
+ folderRoot: true
+ }
+ };
+
+ try {
+ let bruContent = fs.readFileSync(pathname, 'utf8');
+
+ file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -249,33 +253,99 @@ 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);
}
}
};
-const addDirectory = (win, pathname, collectionUid, collectionPath) => {
+const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
return;
}
+ let name = path.basename(pathname);
+ let seq = 1;
+ const folderBruFilePath = path.join(pathname, `folder.bru`);
+
+ if (fs.existsSync(folderBruFilePath)) {
+ let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
+ let folderBruData = await collectionBruToJson(folderBruFileContent);
+ name = folderBruData?.meta?.name || name;
+ seq = folderBruData?.meta?.seq || seq;
+ }
+
const directory = {
meta: {
collectionUid,
pathname,
- name: path.basename(pathname)
+ name,
+ seq,
+ uid: getRequestUid(pathname)
}
};
+
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
@@ -306,7 +376,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
const payload = {
collectionUid,
processEnvVariables: {
- ...process.env,
...jsonData
}
};
@@ -333,7 +402,30 @@ 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;
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+ }
+
+ if (path.basename(pathname) === 'folder.bru') {
+ const file = {
+ meta: {
+ collectionUid,
+ pathname,
+ name: path.basename(pathname),
+ folderRoot: true
+ }
+ };
+
+ try {
+ let bruContent = fs.readFileSync(pathname, 'utf8');
+
+ file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
@@ -355,7 +447,8 @@ 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);
} catch (err) {
@@ -365,6 +458,8 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const unlink = (win, pathname, collectionUid, collectionPath) => {
+ console.log(`watcher unlink: ${pathname}`);
+
if (isBruEnvironmentConfig(pathname, collectionPath)) {
return unlinkEnvironmentFile(win, pathname, collectionUid);
}
@@ -381,46 +476,62 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
}
};
-const unlinkDir = (win, pathname, collectionUid, collectionPath) => {
+const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
return;
}
+
+ const folderBruFilePath = path.join(pathname, `folder.bru`);
+
+ let name = path.basename(pathname);
+
+ if (fs.existsSync(folderBruFilePath)) {
+ let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
+ let folderBruData = await collectionBruToJson(folderBruFileContent);
+ name = folderBruData?.meta?.name || name;
+ }
+
const directory = {
meta: {
collectionUid,
pathname,
- name: path.basename(pathname)
+ name
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
};
+const onWatcherSetupComplete = (win, watchPath) => {
+ const UiStateSnapshotStore = new UiStateSnapshot();
+ const collectionsSnapshotState = UiStateSnapshotStore.getCollections();
+ const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath);
+ win.webContents.send('main:hydrate-app-with-ui-state-snapshot', collectionSnapshotState);
+};
+
class Watcher {
constructor() {
this.watchers = {};
}
- addWatcher(win, watchPath, collectionUid, brunoConfig) {
+ addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) {
if (this.watchers[watchPath]) {
this.watchers[watchPath].close();
}
const ignores = brunoConfig?.ignore || [];
- const self = this;
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
- usePolling: watchPath.startsWith('\\\\') ? true : false,
+ usePolling: isWSLPath(watchPath) || forcePolling ? true : false,
ignored: (filepath) => {
- const normalizedPath = filepath.replace(/\\/g, '/');
+ const normalizedPath = normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => {
- const normalizedIgnorePattern = ignorePattern.replace(/\\/g, '/');
- return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
+ return relativePath === ignorePattern || relativePath.startsWith(ignorePattern);
});
},
persistent: true,
@@ -429,17 +540,41 @@ class Watcher {
stabilityThreshold: 80,
pollInterval: 10
},
- depth: 20
+ depth: 20,
+ disableGlobbing: true
});
+ let startedNewWatcher = false;
watcher
- .on('add', (pathname) => add(win, pathname, collectionUid, watchPath))
+ .on('ready', () => onWatcherSetupComplete(win, 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))
- .on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath));
+ .on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath))
+ .on('error', (error) => {
+ // `EMFILE` is an error code thrown when to many files are watched at the same time see: https://github.com/usebruno/bruno/issues/627
+ // `ENOSPC` stands for "Error No space" but is also thrown if the file watcher limit is reached.
+ // To prevent loops `!forcePolling` is checked.
+ if ((error.code === 'ENOSPC' || error.code === 'EMFILE') && !startedNewWatcher && !forcePolling) {
+ // This callback is called for every file the watcher is trying to watch. To prevent a spam of messages and
+ // Multiple watcher being started `startedNewWatcher` is set to prevent this.
+ startedNewWatcher = true;
+ watcher.close();
+ console.error(
+ `\nCould not start watcher for ${watchPath}:`,
+ 'ENOSPC: System limit for number of file watchers reached!',
+ 'Trying again with polling, this will be slower!\n',
+ '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, useWorkerThread);
+ } else {
+ console.error(`An error occurred in the watcher for: ${watchPath}`, error);
+ }
+ });
- self.watchers[watchPath] = watcher;
+ this.watchers[watchPath] = watcher;
}, 100);
}
@@ -453,6 +588,33 @@ class Watcher {
this.watchers[watchPath] = null;
}
}
+
+ getWatcherByItemPath(itemPath) {
+ const paths = Object.keys(this.watchers);
+
+ const watcherPath = paths?.find(collectionPath => {
+ const absCollectionPath = path.resolve(collectionPath);
+ const absItemPath = path.resolve(itemPath);
+
+ return absItemPath.startsWith(absCollectionPath);
+ });
+
+ return watcherPath ? this.watchers[watcherPath] : null;
+ }
+
+ unlinkItemPathInWatcher(itemPath) {
+ const watcher = this.getWatcherByItemPath(itemPath);
+ if (watcher) {
+ watcher.unwatch(itemPath);
+ }
+ }
+
+ addItemPathInWatcher(itemPath) {
+ const watcher = this.getWatcherByItemPath(itemPath);
+ if (watcher && !watcher?.has?.(itemPath)) {
+ watcher?.add?.(itemPath);
+ }
+ }
}
module.exports = Watcher;
diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js
index de1080ac0..946d95519 100644
--- a/packages/bruno-electron/src/bru/index.js
+++ b/packages/bruno-electron/src/bru/index.js
@@ -7,14 +7,16 @@ 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: {
- params: _.get(json, 'query', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
@@ -24,37 +26,58 @@ const collectionBruToJson = (bru) => {
docs: _.get(json, 'docs', '')
};
+ // add meta if it exists
+ // this is only for folder bru file
+ // in the future, all of this will be replaced by standard bru lang
+ const sequence = _.get(json, 'meta.seq');
+ if (json?.meta) {
+ transformedJson.meta = {
+ name: json.meta.name,
+ seq: !isNaN(sequence) ? Number(sequence) : 1
+ };
+ }
+
return transformedJson;
} catch (error) {
return Promise.reject(error);
}
};
-const jsonToCollectionBru = (json) => {
+const jsonToCollectionBru = async (json, isFolder) => {
try {
const collectionBruJson = {
- query: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
- auth: _.get(json, 'request.auth', {}),
script: {
req: _.get(json, 'request.script.req', ''),
res: _.get(json, 'request.script.res', '')
},
vars: {
req: _.get(json, 'request.vars.req', []),
- res: _.get(json, 'request.vars.req', [])
+ res: _.get(json, 'request.vars.res', [])
},
tests: _.get(json, 'request.tests', ''),
+ auth: _.get(json, 'request.auth', {}),
docs: _.get(json, 'docs', '')
};
+ // add meta if it exists
+ // this is only for folder bru file
+ // in the future, all of this will be replaced by standard bru lang
+ const sequence = _.get(json, 'meta.seq');
+ if (json?.meta) {
+ collectionBruJson.meta = {
+ name: json.meta.name,
+ seq: !isNaN(sequence) ? Number(sequence) : 1
+ };
+ }
+
return _jsonToCollectionBru(collectionBruJson);
} catch (error) {
return Promise.reject(error);
}
};
-const bruToEnvJson = (bru) => {
+const bruToEnvJson = async (bru) => {
try {
const json = bruToEnvJsonV2(bru);
@@ -71,7 +94,7 @@ const bruToEnvJson = (bru) => {
}
};
-const envJsonToBru = (json) => {
+const envJsonToBru = async (json) => {
try {
const bru = envJsonToBruV2(json);
return bru;
@@ -86,12 +109,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') {
@@ -103,7 +126,6 @@ const bruToJson = (bru) => {
}
const sequence = _.get(json, 'meta.seq');
-
const transformedJson = {
type: requestType,
name: _.get(json, 'meta.name'),
@@ -111,7 +133,7 @@ const bruToJson = (bru) => {
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
- params: _.get(json, 'query', []),
+ params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
@@ -131,6 +153,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.
*
@@ -140,7 +172,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';
@@ -150,11 +182,12 @@ const jsonToBru = (json) => {
type = 'http';
}
+ const sequence = _.get(json, 'seq');
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
- seq: _.get(json, 'seq')
+ seq: !isNaN(sequence) ? Number(sequence) : 1
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
@@ -162,7 +195,7 @@ const jsonToBru = (json) => {
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
- query: _.get(json, 'request.params', []),
+ params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
@@ -176,14 +209,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..51030b9ed
--- /dev/null
+++ b/packages/bruno-electron/src/bru/workers/index.js
@@ -0,0 +1,64 @@
+const { sizeInMB } = require("../../utils/filesystem");
+const WorkerQueue = require("../../workers");
+const path = require("path");
+
+const getSize = (data) => {
+ return sizeInMB(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.005
+},{
+ maxSize: 0.1
+},{
+ maxSize: 1
+},{
+ maxSize: 10
+},{
+ 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..92086c4b6
--- /dev/null
+++ b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js
@@ -0,0 +1,16 @@
+const { parentPort } = require('worker_threads');
+const {
+ bruToJsonV2,
+} = require('@usebruno/lang');
+
+parentPort.on('message', (workerData) => {
+ 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..c2a4f88e4
--- /dev/null
+++ b/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js
@@ -0,0 +1,16 @@
+const { parentPort } = require('worker_threads');
+const {
+ jsonToBruV2,
+} = require('@usebruno/lang');
+
+parentPort.on('message', (workerData) => {
+ 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/cache/requestUids.js b/packages/bruno-electron/src/cache/requestUids.js
index 55f7fc291..c6c3bfe7c 100644
--- a/packages/bruno-electron/src/cache/requestUids.js
+++ b/packages/bruno-electron/src/cache/requestUids.js
@@ -6,7 +6,7 @@
* In the past, we used to generate unique ids based on the
* pathname of the request, but we faced problems when implementing
* functionality where the user can move the request to a different
- * location. In that case, the uid would change, and the we would
+ * location. In that case, the uid would change, and we would
* lose the request's draft state if the user has made some changes
*/
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 7f4e58422..436403a49 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -1,9 +1,26 @@
+const fs = require('fs');
const path = require('path');
const isDev = require('electron-is-dev');
+
+if (isDev) {
+ if(!fs.existsSync(path.join(__dirname, '../../bruno-js/src/sandbox/bundle-browser-rollup.js'))) {
+ console.log('JS Sandbox libraries have not been bundled yet');
+ console.log('Please run the below command \nnpm run sandbox:bundle-libraries --workspace=packages/bruno-js');
+ throw new Error('JS Sandbox libraries have not been bundled yet');
+ }
+}
+
const { format } = require('url');
-const { BrowserWindow, app, Menu, ipcMain } = require('electron');
+const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
+if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
+ console.debug("`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n"
+ + `\t${app.getPath("userData")} -> ${process.env.ELECTRON_USER_DATA_PATH}`);
+
+ app.setPath('userData', process.env.ELECTRON_USER_DATA_PATH);
+}
+
const menuTemplate = require('./app/menu-template');
const { openCollection } = require('./app/collections');
const LastOpenedCollections = require('./store/last-opened-collections');
@@ -13,15 +30,17 @@ const registerPreferencesIpc = require('./ipc/preferences');
const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
+const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
+const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const lastOpenedCollections = new LastOpenedCollections();
// Reference: https://content-security-policy.com/
const contentSecurityPolicy = [
"default-src 'self'",
- "script-src * 'unsafe-inline' 'unsafe-eval'",
- "connect-src * 'unsafe-inline'",
- "font-src 'self' https:",
+ "connect-src 'self' https://*.posthog.com",
+ "font-src 'self' https: data:;",
+ "frame-src data:",
// this has been commented out to make oauth2 work
// "form-action 'none'",
// we make an exception and allow http for images so that
@@ -40,6 +59,24 @@ let watcher;
// Prepare the renderer once the app is ready
app.on('ready', async () => {
+
+ if (isDev) {
+ const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
+ try {
+ const extensions = await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS], {
+ loadExtensionOptions: {allowFileAccess: true},
+ })
+ console.log(`Added Extensions: ${extensions.map(ext => ext.name).join(", ")}`)
+ await require("node:timers/promises").setTimeout(1000);
+ session.defaultSession.getAllExtensions().map((ext) => {
+ console.log(`Loading Extension: ${ext.name}`);
+ session.defaultSession.loadExtension(ext.path)
+ });
+ } catch (err) {
+ console.error('An error occurred while loading extensions: ', err);
+ }
+ }
+
Menu.setApplicationMenu(menu);
const { maximized, x, y, width, height } = loadWindowState();
@@ -50,6 +87,7 @@ app.on('ready', async () => {
height,
minWidth: 1000,
minHeight: 640,
+ show: false,
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
@@ -67,6 +105,9 @@ app.on('ready', async () => {
mainWindow.maximize();
}
+ mainWindow.once('ready-to-show', () => {
+ mainWindow.show();
+ });
const url = isDev
? 'http://localhost:3000'
: format({
@@ -115,13 +156,31 @@ app.on('ready', async () => {
}
});
- mainWindow.webContents.setWindowOpenHandler((details) => {
- require('electron').shell.openExternal(details.url);
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ try {
+ const { protocol } = new URL(url);
+ if (['https:', 'http:'].includes(protocol)) {
+ require('electron').shell.openExternal(url);
+ }
+ } catch (e) {
+ console.error(e);
+ }
return { action: 'deny' };
});
+ mainWindow.webContents.on('did-finish-load', () => {
+ let ogSend = mainWindow.webContents.send;
+ mainWindow.webContents.send = function(channel, ...args) {
+ return ogSend.apply(this, [channel, ...args?.map(_ => {
+ // todo: replace this with @msgpack/msgpack encode/decode
+ return safeParseJSON(safeStringifyJSON(_));
+ })]);
+ }
+ });
+
// register all ipc handlers
registerNetworkIpc(mainWindow);
+ registerGlobalEnvironmentsIpc(mainWindow);
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, watcher);
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 648f893e4..b0924bb6b 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -1,11 +1,15 @@
const _ = require('lodash');
const fs = require('fs');
+const fsPromises = require('fs/promises');
+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, jsonToBru, jsonToBruViaWorker, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
+const brunoConverters = require('@usebruno/converters');
+const { postmanToBruno } = brunoConverters;
const {
- isValidPathname,
writeFile,
hasBruExtension,
isDirectory,
@@ -13,15 +17,40 @@ const {
browseFiles,
createDirectory,
searchForBruFiles,
- sanitizeDirectoryName
+ sanitizeName,
+ isWSLPath,
+ safeToRename,
+ isWindowsOS,
+ validateName,
+ hasSubDirectories,
+ getCollectionStats,
+ sizeInMB,
+ safeWriteFileSync,
+ copyPath,
+ removePath,
+ getPaths
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
-const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cookies');
+const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
+const CollectionSecurityStore = require('../store/collection-security');
+const UiStateSnapshotStore = require('../store/ui-state-snapshot');
+const interpolateVars = require('./network/interpolate-vars');
+const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
+const { getProcessEnvVars } = require('../store/process-env');
+const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2');
+const { getCertsAndProxyConfig } = require('./network');
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 = 20;
+const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 5;
+const MAX_COLLECTION_FILES_COUNT = 2000;
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -40,13 +69,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// browse directory for file
- ipcMain.handle('renderer:browse-files', async (event, pathname, request, filters) => {
+ ipcMain.handle('renderer:browse-files', async (_, filters, properties) => {
try {
- const filePaths = await browseFiles(mainWindow, filters);
-
- return filePaths;
+ return await browseFiles(mainWindow, filters, properties);
} catch (error) {
- return Promise.reject(error);
+ throw error;
}
});
@@ -55,16 +82,23 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
'renderer:create-collection',
async (event, collectionName, collectionFolderName, collectionLocation) => {
try {
+ collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
- throw new Error(`collection: ${dirPath} already exists`);
+ const files = fs.readdirSync(dirPath);
+
+ if (files.length > 0) {
+ throw new Error(`collection: ${dirPath} already exists and is not empty`);
+ }
}
- if (!isValidPathname(dirPath)) {
- throw new Error(`collection: invalid pathname - ${dir}`);
+ if (!validateName(path.basename(dirPath))) {
+ throw new Error(`collection: invalid pathname - ${dirPath}`);
}
- await createDirectory(dirPath);
+ if (!fs.existsSync(dirPath)) {
+ await createDirectory(dirPath);
+ }
const uid = generateUidBasedOnHash(dirPath);
const brunoConfig = {
@@ -76,6 +110,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) {
@@ -87,13 +125,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle(
'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
+ collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
}
- if (!isValidPathname(dirPath)) {
- throw new Error(`collection: invalid pathname - ${dir}`);
+ if (!validateName(path.basename(dirPath))) {
+ throw new Error(`collection: invalid pathname - ${dirPath}`);
}
// create dir
@@ -104,15 +143,15 @@ 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;
- const cont = await stringifyJson(json);
+ // Change new name of collection
+ let brunoConfig = JSON.parse(content);
+ brunoConfig.name = collectionName;
+ const cont = await stringifyJson(brunoConfig);
// write the bruno.json to new dir
await writeFile(path.join(dirPath, 'bruno.json'), cont);
- // Now copy all the files with extension name .bru along with there dir
+ // Now copy all the files with extension name .bru along with the dir
const files = searchForBruFiles(previousPath);
for (const sourceFilePath of files) {
@@ -125,7 +164,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);
}
);
@@ -152,11 +195,32 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
+ ipcMain.handle('renderer:save-folder-root', async (event, folder) => {
+ try {
+ const { name: folderName, root: folderRoot = {}, pathname: folderPathname } = folder;
+ const folderBruFilePath = path.join(folderPathname, 'folder.bru');
+
+ if (!folderRoot.meta) {
+ folderRoot.meta = {
+ name: folderName,
+ seq: 1
+ };
+ }
+
+ const content = await jsonToCollectionBru(
+ folderRoot,
+ true // isFolder
+ );
+ await writeFile(folderBruFilePath, content);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
ipcMain.handle('renderer:save-collection-root', async (event, collectionPathname, collectionRoot) => {
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);
@@ -169,8 +233,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
-
- const content = jsonToBru(request);
+ // For the actual filename part, we want to be strict
+ if (!validateName(request?.filename)) {
+ throw new Error(`${request.filename}.bru is not a valid filename`);
+ }
+ const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -184,7 +251,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);
@@ -202,7 +269,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) {
@@ -232,7 +299,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) {
@@ -257,7 +324,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);
@@ -274,7 +341,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
const newEnvFilePath = path.join(envDirPath, `${newName}.bru`);
- if (fs.existsSync(newEnvFilePath)) {
+ if (!safeToRename(envFilePath, newEnvFilePath)) {
throw new Error(`environment: ${newEnvFilePath} already exists`);
}
@@ -304,52 +371,164 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// rename item
- ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
+ ipcMain.handle('renderer:rename-item-name', async (event, { itemPath, newName }) => {
try {
+
+ if (!fs.existsSync(itemPath)) {
+ throw new Error(`path: ${itemPath} does not exist`);
+ }
+
+ if (isDirectory(itemPath)) {
+ const folderBruFilePath = path.join(itemPath, 'folder.bru');
+ let folderBruFileJsonContent;
+ if (fs.existsSync(folderBruFilePath)) {
+ const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
+ folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
+ folderBruFileJsonContent.meta.name = newName;
+ } else {
+ folderBruFileJsonContent = {
+ meta: {
+ name: newName,
+ seq: 1
+ }
+ };
+ }
+
+ const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
+ await writeFile(folderBruFilePath, folderBruFileContent);
+
+ return;
+ }
+
+ const isBru = hasBruExtension(itemPath);
+ if (!isBru) {
+ throw new Error(`path: ${itemPath} is not a bru file`);
+ }
+
+ const data = fs.readFileSync(itemPath, 'utf8');
+ const jsonData = await bruToJson(data);
+ jsonData.name = newName;
+ const content = await jsonToBru(jsonData);
+ await writeFile(itemPath, content);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ // rename item
+ ipcMain.handle('renderer:rename-item-filename', async (event, { oldPath, newPath, newName, newFilename }) => {
+ const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
+ const isWindowsOSAndNotWSLPathAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
+ try {
+ // Check if the old path exists
if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`);
}
- if (fs.existsSync(newPath)) {
- throw new Error(`path: ${oldPath} already exists`);
+
+ if (!safeToRename(oldPath, newPath)) {
+ throw new Error(`path: ${newPath} already exists`);
}
- // if its directory, rename and return
if (isDirectory(oldPath)) {
+ const folderBruFilePath = path.join(oldPath, 'folder.bru');
+ let folderBruFileJsonContent;
+ if (fs.existsSync(folderBruFilePath)) {
+ const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
+ folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
+ folderBruFileJsonContent.meta.name = newName;
+ } else {
+ folderBruFileJsonContent = {
+ meta: {
+ name: newName,
+ seq: 1
+ }
+ };
+ }
+
+ const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
+ await writeFile(folderBruFilePath, folderBruFileContent);
+
const bruFilesAtSource = await searchForBruFiles(oldPath);
for (let bruFile of bruFilesAtSource) {
const newBruFilePath = bruFile.replace(oldPath, newPath);
moveRequestUid(bruFile, newBruFilePath);
}
- return fs.renameSync(oldPath, newPath);
+
+ /**
+ * If it is windows OS
+ * And it is not a WSL path (meaning it is not running in WSL (linux pathtype))
+ * And it has sub directories
+ * Only then we need to use the temp dir approach to rename the folder
+ *
+ * Windows OS would sometimes throw error when renaming a folder with sub directories
+ * This is an alternative approach to avoid that error
+ */
+ if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
+ await fsExtra.copy(oldPath, tempDir);
+ await fsExtra.remove(oldPath);
+ await fsExtra.move(tempDir, newPath, { overwrite: true });
+ await fsExtra.remove(tempDir);
+ } else {
+ await fs.renameSync(oldPath, newPath);
+ }
+
+ return newPath;
}
- const isBru = hasBruExtension(oldPath);
- if (!isBru) {
+ if (!hasBruExtension(oldPath)) {
throw new Error(`path: ${oldPath} is not a bru file`);
}
+ if (!validateName(newFilename)) {
+ throw new Error(`path: ${newFilename} is not a valid filename`);
+ }
+
// update name in file and save new copy, then delete old copy
- const data = fs.readFileSync(oldPath, 'utf8');
- const jsonData = bruToJson(data);
-
+ const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
+ 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);
- await fs.unlinkSync(oldPath);
+
+ return newPath;
} catch (error) {
+ // in case the rename file operations fails, and we see that the temp dir exists
+ // and the old path does not exist, we need to restore the data from the temp dir to the old path
+ if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
+ if (fsExtra.pathExistsSync(tempDir) && !fsExtra.pathExistsSync(oldPath)) {
+ try {
+ await fsExtra.copy(tempDir, oldPath);
+ await fsExtra.remove(tempDir);
+ } catch (err) {
+ console.error("Failed to restore data to the old path:", err);
+ }
+ }
+ }
+
return Promise.reject(error);
}
});
// new folder
- ipcMain.handle('renderer:new-folder', async (event, pathname) => {
+ ipcMain.handle('renderer:new-folder', async (event, pathname, folderName) => {
+ const resolvedFolderName = sanitizeName(path.basename(pathname));
+ pathname = path.join(path.dirname(pathname), resolvedFolderName);
try {
if (!fs.existsSync(pathname)) {
fs.mkdirSync(pathname);
+ const folderBruFilePath = path.join(pathname, 'folder.bru');
+ let data = {
+ meta: {
+ name: folderName,
+ seq: 1
+ }
+ };
+ const content = await jsonToCollectionBru(data, true); // isFolder flag
+ await writeFile(folderBruFilePath, content);
} else {
return Promise.reject(new Error('The directory already exists'));
}
@@ -403,9 +582,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
+ ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => {
+ lastOpenedCollections.update(collectionPaths);
+ })
+
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try {
- let collectionName = sanitizeDirectoryName(collection.name);
+ let collectionName = sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) {
@@ -414,24 +597,37 @@ 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 filePath = path.join(currentPath, `${item.name}.bru`);
- fs.writeFileSync(filePath, content);
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
+ const content = await jsonToBruViaWorker(item);
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
- const folderPath = path.join(currentPath, item.name);
+ let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
+ const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
+ if (item?.root?.meta?.name) {
+ const folderBruFilePath = path.join(folderPath, 'folder.bru');
+ item.root.meta.seq = item.seq;
+ const folderContent = await jsonToCollectionBru(
+ item.root,
+ true // isFolder
+ );
+ safeWriteFileSync(folderBruFilePath, folderContent);
+ }
+
if (item.items && item.items.length) {
parseCollectionItems(item.items, folderPath);
}
}
// Handle items of type 'js'
if (item.type === 'js') {
- const filePath = path.join(currentPath, `${item.name}.js`);
- fs.writeFileSync(filePath, item.fileContent);
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, item.fileContent);
}
});
};
@@ -442,10 +638,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.mkdirSync(envDirPath);
}
- environments.forEach((env) => {
- const content = envJsonToBru(env);
- const filePath = path.join(envDirPath, `${env.name}.bru`);
- fs.writeFileSync(filePath, content);
+ environments.forEach(async (env) => {
+ const content = await envJsonToBru(env);
+ let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
+ const filePath = path.join(envDirPath, sanitizedEnvFilename);
+ safeWriteFileSync(filePath, content);
});
};
@@ -467,12 +664,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 = 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);
@@ -494,16 +698,26 @@ 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 filePath = path.join(currentPath, `${item.name}.bru`);
- fs.writeFileSync(filePath, content);
+ const content = await jsonToBruViaWorker(item);
+ const filePath = path.join(currentPath, item.filename);
+ safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
- const folderPath = path.join(currentPath, item.name);
+ const folderPath = path.join(currentPath, item.filename);
fs.mkdirSync(folderPath);
+ // If folder has a root element, then I should write its folder.bru file
+ if (item.root) {
+ const folderContent = await jsonToCollectionBru(item.root, true);
+ folderContent.name = item.name;
+ if (folderContent) {
+ const bruFolderPath = path.join(folderPath, `folder.bru`);
+ safeWriteFileSync(bruFolderPath, folderContent);
+ }
+ }
+
if (item.items && item.items.length) {
parseCollectionItems(item.items, folderPath);
}
@@ -513,6 +727,15 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(collectionPath);
+ // If initial folder has a root element, then I should write its folder.bru file
+ if (itemFolder.root) {
+ const folderContent = await jsonToCollectionBru(itemFolder.root, true);
+ if (folderContent) {
+ const bruFolderPath = path.join(collectionPath, `folder.bru`);
+ safeWriteFileSync(bruFolderPath, folderContent);
+ }
+ }
+
// create folder and files based on another folder
await parseCollectionItems(itemFolder.items, collectionPath);
} catch (error) {
@@ -523,16 +746,41 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for (let item of itemsToResequence) {
- const bru = fs.readFileSync(item.pathname, 'utf8');
- const jsonData = bruToJson(bru);
-
- if (jsonData.seq !== item.seq) {
- jsonData.seq = item.seq;
- const content = jsonToBru(jsonData);
- await writeFile(item.pathname, content);
+ if (item?.type === 'folder') {
+ const folderRootPath = path.join(item.pathname, 'folder.bru');
+ let folderBruJsonData = {
+ meta: {
+ name: path.basename(item?.pathname),
+ seq: item?.seq || 1
+ }
+ };
+ if (fs.existsSync(folderRootPath)) {
+ const bru = fs.readFileSync(folderRootPath, 'utf8');
+ folderBruJsonData = await collectionBruToJson(bru);
+ if (!folderBruJsonData?.meta) {
+ folderBruJsonData.meta = {
+ name: path.basename(item?.pathname),
+ seq: item?.seq || 1
+ };
+ }
+ if (folderBruJsonData?.meta?.seq === item.seq) {
+ continue;
+ }
+ folderBruJsonData.meta.seq = item.seq;
+ }
+ const content = await jsonToCollectionBru(folderBruJsonData);
+ await writeFile(folderRootPath, content);
+ } else {
+ if (fs.existsSync(item.pathname)) {
+ const itemToSave = transformRequestToSaveToFilesystem(item);
+ const content = await jsonToBruViaWorker(itemToSave);
+ await writeFile(item.pathname, content);
+ }
}
}
+ return true;
} catch (error) {
+ console.error('Error in resequence-items:', error);
return Promise.reject(error);
}
});
@@ -545,7 +793,25 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(itemPath, newItemPath);
fs.unlinkSync(itemPath);
- fs.writeFileSync(newItemPath, itemContent);
+ safeWriteFileSync(newItemPath, itemContent);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:move-item', async (event, { targetDirname, sourcePathname }) => {
+ try {
+ if (fs.existsSync(targetDirname)) {
+ const sourceDirname = path.dirname(sourcePathname);
+ const pathnamesBefore = await getPaths(sourcePathname);
+ const pathnamesAfter = pathnamesBefore?.map(p => p?.replace(sourceDirname, targetDirname));
+ await copyPath(sourcePathname, targetDirname);
+ await removePath(sourcePathname);
+ // move the request uids of the previous file/folders to the new file/folder items
+ pathnamesAfter?.forEach((_, index) => {
+ moveRequestUid(pathnamesBefore[index], pathnamesAfter[index]);
+ });
+ }
} catch (error) {
return Promise.reject(error);
}
@@ -601,7 +867,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
const jsonData = fs.readFileSync(filePaths[0], 'utf8');
- return JSON.parse(jsonData);
+ return safeParseJSON(jsonData);
} catch (err) {
return Promise.reject(new Error('Failed to load GraphQL schema file'));
}
@@ -617,6 +883,289 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return Promise.reject(error);
}
});
+
+ ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => {
+ try {
+ await deleteCookie(domain, path, cookieKey);
+ const domainsWithCookies = await getDomainsWithCookies();
+ mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ // add cookie
+ ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => {
+ try {
+ await addCookieForDomain(domain, cookie);
+ const domainsWithCookies = await getDomainsWithCookies();
+ mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ // modify cookie
+ ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => {
+ try {
+ await modifyCookieForDomain(domain, oldCookie, cookie);
+ const domainsWithCookies = await getDomainsWithCookies();
+ mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:get-parsed-cookie', async (event, cookieStr) => {
+ try {
+ return parseCookieString(cookieStr);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:create-cookie-string', async (event, cookie) => {
+ try {
+ return createCookieString(cookie);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:save-collection-security-config', async (event, collectionPath, securityConfig) => {
+ try {
+ collectionSecurityStore.setSecurityConfigForCollection(collectionPath, {
+ jsSandboxMode: securityConfig.jsSandboxMode
+ });
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:get-collection-security-config', async (event, collectionPath) => {
+ try {
+ return collectionSecurityStore.getSecurityConfigForCollection(collectionPath);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:update-ui-state-snapshot', (event, { type, data }) => {
+ try {
+ uiStateSnapshotStore.update({ type, data });
+ } catch (error) {
+ throw new Error(error.message);
+ }
+ });
+
+ ipcMain.handle('renderer:fetch-oauth2-credentials', async (event, { itemUid, request, collection }) => {
+ try {
+ if (request.oauth2) {
+ let requestCopy = _.cloneDeep(request);
+ const { uid: collectionUid, pathname: collectionPath, runtimeVariables, environments = [], activeEnvironmentUid } = collection;
+ const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid);
+ const envVars = getEnvVars(environment);
+ const processEnvVars = getProcessEnvVars(collectionUid);
+ const partialItem = { uid: itemUid };
+ const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem);
+ if (requestTreePath && requestTreePath.length > 0) {
+ mergeVars(collection, requestCopy, requestTreePath);
+ }
+
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ const certsAndProxyConfig = await getCertsAndProxyConfig({
+ collectionUid,
+ request: requestCopy,
+ envVars,
+ runtimeVariables,
+ processEnvVars,
+ collectionPath
+ });
+ const { oauth2: { grantType }} = requestCopy || {};
+ let credentials, url, credentialsId;
+ switch (grantType) {
+ case 'authorization_code':
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
+ break;
+ case 'client_credentials':
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
+ break;
+ case 'password':
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, forceFetch: true, certsAndProxyConfig }));
+ break;
+ }
+ return { credentials, url, collectionUid, credentialsId, debugInfo };
+ }
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ 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:refresh-oauth2-credentials', async (event, { request, collection }) => {
+ try {
+ if (request.oauth2) {
+ let requestCopy = _.cloneDeep(request);
+ const { uid: collectionUid, pathname: collectionPath, runtimeVariables, environments = [], activeEnvironmentUid } = collection;
+ const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid);
+ const envVars = getEnvVars(environment);
+ const processEnvVars = getProcessEnvVars(collectionUid);
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ const certsAndProxyConfig = await getCertsAndProxyConfig({
+ collectionUid,
+ request: requestCopy,
+ envVars,
+ runtimeVariables,
+ processEnvVars,
+ collectionPath
+ });
+ let { credentials, url, credentialsId, debugInfo } = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
+ return { credentials, url, collectionUid, credentialsId, debugInfo };
+ }
+ } catch (error) {
+ 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;
+ }
+ });
+
+ // Implement the Postman to Bruno conversion handler
+ ipcMain.handle('renderer:convert-postman-to-bruno', async (event, postmanCollection) => {
+ try {
+ // Convert Postman collection to Bruno format
+ const brunoCollection = await postmanToBruno(postmanCollection, { useWorkers: true});
+
+ return brunoCollection;
+ } catch (error) {
+ console.error('Error converting Postman to Bruno:', error);
+ return Promise.reject(error);
+ }
+ });
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
@@ -631,8 +1180,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/global-environments.js b/packages/bruno-electron/src/ipc/global-environments.js
new file mode 100644
index 000000000..dc7258ee1
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/global-environments.js
@@ -0,0 +1,50 @@
+require('dotenv').config();
+const { ipcMain } = require('electron');
+const { globalEnvironmentsStore } = require('../store/global-environments');
+
+const registerGlobalEnvironmentsIpc = (mainWindow) => {
+
+ // GLOBAL ENVIRONMENTS
+
+ ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => {
+ try {
+ globalEnvironmentsStore.addGlobalEnvironment({ uid, name, variables });
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables }) => {
+ try {
+ globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables })
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name }) => {
+ try {
+ globalEnvironmentsStore.renameGlobalEnvironment({ environmentUid, name });
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid }) => {
+ try {
+ globalEnvironmentsStore.deleteGlobalEnvironment({ environmentUid });
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid }) => {
+ try {
+ globalEnvironmentsStore.selectGlobalEnvironment({ environmentUid });
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+};
+
+module.exports = registerGlobalEnvironmentsIpc;
\ No newline at end of file
diff --git a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js
index 3ed05d45c..7d2e23abc 100644
--- a/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js
+++ b/packages/bruno-electron/src/ipc/network/authorize-user-in-window.js
@@ -1,15 +1,22 @@
const { BrowserWindow } = require('electron');
const { preferencesUtil } = require('../../store/preferences');
+const matchesCallbackUrl = (url, callbackUrl) => {
+ return url ? url.href.startsWith(callbackUrl.href) : false;
+};
+
const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
return new Promise(async (resolve, reject) => {
let finalUrl = null;
+ let debugInfo = {
+ data: []
+ };
+ let currentMainRequest = null;
let allOpenWindows = BrowserWindow.getAllWindows();
- // main window id is '1'
- // get all other windows
- let windowsExcludingMain = allOpenWindows.filter((w) => w.id != 1);
+ // Close all windows except the main window (assumed to have id 1)
+ let windowsExcludingMain = allOpenWindows.filter((w) => w.id !== 1);
windowsExcludingMain.forEach((w) => {
w.close();
});
@@ -23,26 +30,104 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
});
window.on('ready-to-show', window.show.bind(window));
- // We want browser window to comply with "SSL/TLS Certificate Verification" toggle in Preferences
+ // Ensure the browser window complies with "SSL/TLS Certificate Verification" preference
window.webContents.on('certificate-error', (event, url, error, certificate, callback) => {
event.preventDefault();
callback(!preferencesUtil.shouldVerifyTls());
});
+ const { session: webSession } = window.webContents;
+
+ // Intercept request events and gather data
+ webSession.webRequest.onBeforeRequest((details, callback) => {
+ const { id: requestId, url, method, resourceType, frameId } = details;
+ if (resourceType === 'mainFrame') {
+ // This is a main frame request
+ currentMainRequest = {
+ requestId,
+ resourceType,
+ frameId,
+ request: {
+ url,
+ method,
+ headers: {},
+ error: null
+ },
+ response: {
+ headers: {},
+ status: null,
+ statusText: null,
+ error: null
+ },
+ fromCache: false,
+ completed: true,
+ requests: [], // No sub-requests in this context
+ };
+ // Add to mainRequests
+
+ // pushing the currentMainRequest to debugInfo
+ // the currentMainRequest will be further updated by object reference
+ debugInfo.data.push(currentMainRequest);
+ }
+
+ callback({ cancel: false });
+ });
+
+ webSession.webRequest.onBeforeSendHeaders((details, callback) => {
+ const { id: requestId, requestHeaders, method, url } = details;
+ if (currentMainRequest?.requestId === requestId) {
+ currentMainRequest.request = {
+ url,
+ headers: requestHeaders,
+ method
+ };
+ }
+ callback({ cancel: false, requestHeaders });
+ });
+
+ webSession.webRequest.onHeadersReceived((details, callback) => {
+ const { id: requestId, url, statusCode, responseHeaders, method } = details;
+ if (currentMainRequest?.requestId === requestId) {
+ currentMainRequest.response = {
+ url,
+ method,
+ status: statusCode,
+ headers: responseHeaders
+ };
+ }
+ callback({ cancel: false, responseHeaders });
+ });
+
+ webSession.webRequest.onCompleted((details) => {
+ const { id: requestId, fromCache } = details;
+ if (currentMainRequest?.requestId === requestId) {
+ currentMainRequest.completed = true;
+ currentMainRequest.fromCache = fromCache;
+ }
+ });
+
+ webSession.webRequest.onErrorOccurred((details) => {
+ const { id: requestId, error } = details;
+ if (currentMainRequest?.requestId === requestId) {
+ currentMainRequest.response.error = error;
+ }
+ });
+
function onWindowRedirect(url) {
- // check if the url contains an authorization code
- if (new URL(url).searchParams.has('code')) {
+ // Handle redirects as needed
+
+ // Check if redirect is to the callback URL and contains an authorization code
+ if (matchesCallbackUrl(new URL(url), new URL(callbackUrl))) {
finalUrl = url;
- if (!url || !finalUrl.includes(callbackUrl)) {
- reject(new Error('Invalid Callback Url'));
- }
window.close();
}
- if (url.match(/(error=).*/) || url.match(/(error_description=).*/) || url.match(/(error_uri=).*/)) {
- const _url = new URL(url);
- const error = _url.searchParams.get('error');
- const errorDescription = _url.searchParams.get('error_description');
- const errorUri = _url.searchParams.get('error_uri');
+
+ // Handle OAuth error responses
+ const urlObj = new URL(url);
+ if (urlObj.searchParams.has('error')) {
+ const error = urlObj.searchParams.get('error');
+ const errorDescription = urlObj.searchParams.get('error_description');
+ const errorUri = urlObj.searchParams.get('error_uri');
let errorData = {
message: 'Authorization Failed!',
error,
@@ -54,13 +139,36 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
}
}
+ // Update currentMainRequest when navigation occurs
+ window.webContents.on('did-start-navigation', (event, url, isInPlace, isMainFrame) => {
+ if (isMainFrame) {
+ // Reset currentMainRequest since a new navigation is starting
+ currentMainRequest = null;
+ }
+ });
+
+ window.webContents.on('did-navigate', (event, url) => {
+ onWindowRedirect(url);
+ });
+
+ window.webContents.on('will-redirect', (event, url) => {
+ onWindowRedirect(url);
+ });
+
window.on('close', () => {
+ // Clean up listeners to prevent memory leaks
+ window.webContents.removeAllListeners();
+ webSession.webRequest.onBeforeRequest(null);
+ webSession.webRequest.onBeforeSendHeaders(null);
+ webSession.webRequest.onHeadersReceived(null);
+ webSession.webRequest.onCompleted(null);
+ webSession.webRequest.onErrorOccurred(null);
+
if (finalUrl) {
try {
const callbackUrlWithCode = new URL(finalUrl);
const authorizationCode = callbackUrlWithCode.searchParams.get('code');
-
- return resolve({ authorizationCode });
+ return resolve({ authorizationCode, debugInfo });
} catch (error) {
return reject(error);
}
@@ -69,20 +177,10 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
}
});
- // wait for the window to navigate to the callback url
- const didNavigateListener = (_, url) => {
- onWindowRedirect(url);
- };
- window.webContents.on('did-navigate', didNavigateListener);
- const willRedirectListener = (_, authorizeUrl) => {
- onWindowRedirect(authorizeUrl);
- };
- window.webContents.on('will-redirect', willRedirectListener);
-
try {
await window.loadURL(authorizeUrl);
} catch (error) {
- // If browser redirects before load finished, loadURL throws an error with code ERR_ABORTED. This should be ignored.
+ // Ignore ERR_ABORTED errors that occur during redirects
if (error.code === 'ERR_ABORTED') {
console.debug('Ignoring ERR_ABORTED during authorizeUserInWindow');
return;
@@ -93,4 +191,4 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session }) => {
});
};
-module.exports = { authorizeUserInWindow };
+module.exports = { authorizeUserInWindow, matchesCallbackUrl };
\ No newline at end of file
diff --git a/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js b/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js
index 4a2ff5aa2..8714ae39c 100644
--- a/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js
+++ b/packages/bruno-electron/src/ipc/network/awsv4auth-helper.js
@@ -10,7 +10,8 @@ async function resolveAwsV4Credentials(request) {
if (isStrPresent(awsv4.profileName)) {
try {
credentialsProvider = fromIni({
- profile: awsv4.profileName
+ profile: awsv4.profileName,
+ ignoreCache: true
});
credentials = await credentialsProvider();
awsv4.accessKeyId = credentials.accessKeyId;
diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js
index dcc57a07e..e86d06fea 100644
--- a/packages/bruno-electron/src/ipc/network/axios-instance.js
+++ b/packages/bruno-electron/src/ipc/network/axios-instance.js
@@ -2,10 +2,33 @@ const URL = require('url');
const Socket = require('net').Socket;
const axios = require('axios');
const connectionCache = new Map(); // Cache to store checkConnection() results
+const electronApp = require("electron");
+const { setupProxyAgents } = require('../../utils/proxy-util');
+const { addCookieToJar, getCookieStringForUrl } = require('../../utils/cookies');
+const { preferencesUtil } = require('../../store/preferences');
+const { safeStringifyJSON } = require('../../utils/common');
const LOCAL_IPV6 = '::1';
const LOCAL_IPV4 = '127.0.0.1';
const LOCALHOST = 'localhost';
+const version = electronApp?.app?.getVersion() ?? "";
+const redirectResponseCodes = [301, 302, 303, 307, 308];
+
+const saveCookies = (url, headers) => {
+ if (preferencesUtil.shouldStoreCookies()) {
+ let setCookieHeaders = [];
+ if (headers['set-cookie']) {
+ setCookieHeaders = Array.isArray(headers['set-cookie'])
+ ? headers['set-cookie']
+ : [headers['set-cookie']];
+ for (let setCookieHeader of setCookieHeaders) {
+ if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
+ addCookieToJar(setCookieHeader, url);
+ }
+ }
+ }
+ }
+}
const getTld = (hostname) => {
if (!hostname) {
@@ -47,12 +70,78 @@ const checkConnection = (host, port) =>
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
-function makeAxiosInstance() {
+function makeAxiosInstance({
+ proxyMode = 'off',
+ proxyConfig = {},
+ requestMaxRedirects = 5,
+ httpsAgentRequestFields = {},
+ interpolationOptions = {}
+} = {}) {
/** @type {axios.AxiosInstance} */
- const instance = axios.create();
+ const instance = axios.create({
+ transformRequest: function transformRequest(data, headers) {
+ const contentType = headers?.['Content-Type'] || headers?.['content-type'] || '';
+ const hasJSONContentType = contentType.includes('json');
+ if (typeof data === 'string' && hasJSONContentType) {
+ return data;
+ }
+
+ axios.defaults.transformRequest.forEach(function (tr) {
+ data = tr.call(this, data, headers);
+ }, this);
+ return data;
+ },
+ proxy: false,
+ maxRedirects: 0,
+ headers: {
+ "User-Agent": `bruno-runtime/${version}`
+ }
+ });
instance.interceptors.request.use(async (config) => {
const url = URL.parse(config.url);
+ config.metadata = config.metadata || {};
+ config.metadata.startTime = new Date().getTime();
+ const timeline = config.metadata.timeline || [];
+ // Add initial request details to the timeline
+ timeline.push({
+ timestamp: new Date(),
+ type: 'separator'
+ });
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Preparing request to ${config.url}`,
+ });
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Current time is ${new Date().toISOString()}`,
+ });
+
+ // Add request method and headers
+ timeline.push({
+ timestamp: new Date(),
+ type: 'request',
+ message: `${config.method.toUpperCase()} ${config.url}`,
+ });
+ Object.entries(config.headers).forEach(([key, value]) => {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'requestHeader',
+ message: `${key}: ${value}`,
+ });
+ });
+
+ // Add request data if available
+ if (config.data) {
+ let requestData = typeof config.data === 'string' ? config.data : JSON.stringify(config.data, null, 2);
+ timeline.push({
+ timestamp: new Date(),
+ type: 'requestData',
+ message: requestData,
+ });
+ }
// Resolve all *.localhost to localhost and check if it should use IPv6 or IPv4
// RFC: 6761 section 6.3 (https://tools.ietf.org/html/rfc6761#section-6.3)
@@ -69,21 +158,264 @@ function makeAxiosInstance() {
}
config.headers['request-start-time'] = Date.now();
+
+ const agentOptions = {
+ ...httpsAgentRequestFields,
+ keepAlive: true,
+ };
+
+ try {
+ // Now call setupProxyAgents and pass the timeline
+ setupProxyAgents({
+ requestConfig: config,
+ proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings
+ proxyConfig: proxyConfig,
+ httpsAgentRequestFields: agentOptions,
+ interpolationOptions: interpolationOptions, // Provide your interpolation options
+ timeline,
+ });
+ }
+ catch(err) {
+ if (err.timeline) {
+ timeline = err.timeline;
+ }
+ timeline.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: `Error setting up proxy agents: ${err?.message}`,
+ });
+ }
+ config.metadata.timeline = timeline;
return config;
});
+ let redirectCount = 0
+
instance.interceptors.response.use(
(response) => {
+ let timeline;
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
+ redirectCount = 0;
+
+ const config = response.config;
+ timeline = config?.metadata?.timeline || []
+ const duration = end - config?.metadata.startTime;
+
+ const httpVersion = response?.request?.res?.httpVersion || response?.httpVersion;
+ if (httpVersion?.startsWith('2')) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using HTTP/2, server supports multiplexing`,
+ });
+ }
+ timeline.push({
+ timestamp: new Date(),
+ type: 'response',
+ message: `HTTP/${httpVersion || '1.1'} ${response.status} ${response.statusText}`,
+ });
+
+ Object.entries(response.headers).forEach(([key, value]) => {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'responseHeader',
+ message: `${key}: ${value}`,
+ });
+ });
+
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Request completed in ${duration} ms`,
+ });
+ response.timeline = timeline;
return response;
},
(error) => {
+ const config = error.config;
+ const timeline = config?.metadata?.timeline || [];
+ timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: 'there was an error executing the request!'
+ });
if (error.response) {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
+ const duration = end - config?.metadata?.startTime;
+ if (error.response && redirectResponseCodes.includes(error.response.status)) {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'response',
+ message: `HTTP/${error.response.httpVersion || '1.1'} ${error.response.status} ${error.response.statusText}`,
+ });
+ Object.entries(error.response.headers).forEach(([key, value]) => {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'responseHeader',
+ message: `${key}: ${value}`,
+ });
+ });
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Request completed in ${duration} ms`,
+ });
+
+ // Attach the timeline to the response
+ error.response.timeline = timeline;
+
+ if (redirectCount >= requestMaxRedirects) {
+ const errorResponseData = error.response.data;
+ timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(errorResponseData?.toString?.())
+ });
+ return Promise.reject(error);
+ }
+
+ // Increase redirect count
+ redirectCount++;
+
+ const locationHeader = error.response.headers.location;
+ let redirectUrl = locationHeader;
+
+ // Handle relative URLs by resolving them against the original request URL
+ if (locationHeader && !locationHeader.match(/^https?:\/\//i)) {
+ // It's a relative URL, resolve it against the original URL
+ redirectUrl = URL.resolve(error.config.url, locationHeader);
+
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Resolving relative redirect URL: ${locationHeader} → ${redirectUrl}`,
+ });
+ }
+
+ if (preferencesUtil.shouldStoreCookies()) {
+ saveCookies(redirectUrl, error.response.headers);
+ }
+
+ // Create a new request config for the redirect
+ const requestConfig = {
+ ...error.config,
+ url: redirectUrl,
+ headers: {
+ ...error.config.headers,
+ },
+ };
+
+ // Apply proper HTTP redirect behavior based on status code
+ const statusCode = error.response.status;
+ const originalMethod = (error.config.method || 'get').toLowerCase();
+
+ // For 301, 302, 303: change method to GET unless it was HEAD
+ if ([301, 302, 303].includes(statusCode) && originalMethod !== 'head') {
+ requestConfig.method = 'get';
+ requestConfig.data = undefined;
+ delete requestConfig.headers['content-length'];
+ delete requestConfig.headers['Content-Length'];
+
+ delete requestConfig.headers['content-type'];
+ delete requestConfig.headers['Content-Type'];
+
+ timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Changed method from ${originalMethod.toUpperCase()} to GET for ${statusCode} redirect and removed request body`,
+ });
+ }
+
+ if (preferencesUtil.shouldSendCookies()) {
+ const cookieString = getCookieStringForUrl(redirectUrl);
+ if (cookieString && typeof cookieString === 'string' && cookieString.length) {
+ requestConfig.headers['cookie'] = cookieString;
+ }
+ }
+
+ try {
+ setupProxyAgents({
+ requestConfig,
+ proxyMode,
+ proxyConfig,
+ httpsAgentRequestFields,
+ interpolationOptions,
+ timeline
+ });
+ }
+ catch(err) {
+ if (err.timeline) {
+ timeline = err.timeline;
+ }
+ timeline.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: `Error setting up proxy agents: ${err?.message}`,
+ });
+ }
+
+ requestConfig.metadata.timeline = timeline;
+ // Make the redirected request
+ return instance(requestConfig);
+ }
+ else {
+ const errorResponseData = error.response.data;
+ timeline.push({
+ timestamp: new Date(),
+ type: 'response',
+ message: `HTTP/${error.response.httpVersion || '1.1'} ${error.response.status} ${error.response.statusText}`,
+ });
+ Object.entries(error?.response?.headers || {}).forEach(([key, value]) => {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'responseHeader',
+ message: `${key}: ${value}`,
+ });
+ });
+ timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(errorResponseData?.toString?.())
+ });
+ error?.cause && timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(error?.cause)
+ });
+ error?.errors && timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(error?.errors)
+ });
+ error.response.timeline = timeline;
+ return Promise.reject(error);
+ }
+ }
+ else if (error?.code) {
+ Object.entries(error?.response?.headers || {}).forEach(([key, value]) => {
+ timeline.push({
+ timestamp: new Date(),
+ type: 'responseHeader',
+ message: `${key}: ${value}`,
+ });
+ });
+ timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(error?.cause)
+ });
+ timeline?.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: safeStringifyJSON(error?.errors)
+ });
+ error.timeline = timeline;
+ error.statusText = error.code;
+ return Promise.reject(error);
}
return Promise.reject(error);
}
diff --git a/packages/bruno-electron/src/ipc/network/digestauth-helper.js b/packages/bruno-electron/src/ipc/network/digestauth-helper.js
deleted file mode 100644
index 67f738db5..000000000
--- a/packages/bruno-electron/src/ipc/network/digestauth-helper.js
+++ /dev/null
@@ -1,83 +0,0 @@
-const crypto = require('crypto');
-const { URL } = require('url');
-
-function isStrPresent(str) {
- return str && str !== '' && str !== 'undefined';
-}
-
-function stripQuotes(str) {
- return str.replace(/"/g, '');
-}
-
-function containsDigestHeader(response) {
- const authHeader = response?.headers?.['www-authenticate'];
- return authHeader ? authHeader.trim().toLowerCase().startsWith('digest') : false;
-}
-
-function containsAuthorizationHeader(originalRequest) {
- return Boolean(originalRequest.headers['Authorization']);
-}
-
-function md5(input) {
- return crypto.createHash('md5').update(input).digest('hex');
-}
-
-function addDigestInterceptor(axiosInstance, request) {
- const { username, password } = request.digestConfig;
-
- console.debug(request);
-
- if (!isStrPresent(username) || !isStrPresent(password)) {
- console.warn('Required Digest Auth fields are not present');
- return;
- }
-
- axiosInstance.interceptors.response.use(
- (response) => response,
- (error) => {
- const originalRequest = error.config;
-
- if (
- error.response?.status === 401 &&
- containsDigestHeader(error.response) &&
- !containsAuthorizationHeader(originalRequest)
- ) {
- console.debug(error.response.headers['www-authenticate']);
-
- const authDetails = error.response.headers['www-authenticate']
- .split(', ')
- .map((v) => v.split('=').map(stripQuotes))
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
- console.debug(authDetails);
-
- const nonceCount = '00000001';
- const cnonce = crypto.randomBytes(24).toString('hex');
-
- if (authDetails.algorithm && authDetails.algorithm.toUpperCase() !== 'MD5') {
- console.warn(`Unsupported Digest algorithm: ${algo}`);
- return Promise.reject(error);
- } else {
- authDetails.algorithm = 'MD5';
- }
- const uri = new URL(request.url).pathname;
- const HA1 = md5(`${username}:${authDetails['Digest realm']}:${password}`);
- const HA2 = md5(`${request.method}:${uri}`);
- const response = md5(`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`);
-
- const authorizationHeader =
- `Digest username="${username}",realm="${authDetails['Digest realm']}",` +
- `nonce="${authDetails.nonce}",uri="${uri}",qop="auth",algorithm="${authDetails.algorithm}",` +
- `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
- originalRequest.headers['Authorization'] = authorizationHeader;
- console.debug(`Authorization: ${originalRequest.headers['Authorization']}`);
-
- delete originalRequest.digestConfig;
- return axiosInstance(originalRequest);
- }
-
- return Promise.reject(error);
- }
- );
-}
-
-module.exports = { addDigestInterceptor };
diff --git a/packages/bruno-electron/src/ipc/network/helper.js b/packages/bruno-electron/src/ipc/network/helper.js
deleted file mode 100644
index d4e49688f..000000000
--- a/packages/bruno-electron/src/ipc/network/helper.js
+++ /dev/null
@@ -1,76 +0,0 @@
-const { each, filter } = require('lodash');
-
-const sortCollection = (collection) => {
- const items = collection.items || [];
- let folderItems = filter(items, (item) => item.type === 'folder');
- let requestItems = filter(items, (item) => item.type !== 'folder');
-
- folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
- requestItems = requestItems.sort((a, b) => a.seq - b.seq);
-
- collection.items = folderItems.concat(requestItems);
-
- each(folderItems, (item) => {
- sortCollection(item);
- });
-};
-
-const sortFolder = (folder = {}) => {
- const items = folder.items || [];
- let folderItems = filter(items, (item) => item.type === 'folder');
- let requestItems = filter(items, (item) => item.type !== 'folder');
-
- folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
- requestItems = requestItems.sort((a, b) => a.seq - b.seq);
-
- folder.items = folderItems.concat(requestItems);
-
- each(folderItems, (item) => {
- sortFolder(item);
- });
-
- return folder;
-};
-
-const findItemInCollection = (collection, itemId) => {
- let item = null;
-
- if (collection.uid === itemId) {
- return collection;
- }
-
- if (collection.items && collection.items.length) {
- collection.items.forEach((item) => {
- if (item.uid === itemId) {
- item = item;
- } else if (item.type === 'folder') {
- item = findItemInCollection(item, itemId);
- }
- });
- }
-
- return item;
-};
-
-const getAllRequestsInFolderRecursively = (folder = {}) => {
- let requests = [];
-
- if (folder.items && folder.items.length) {
- folder.items.forEach((item) => {
- if (item.type !== 'folder') {
- requests.push(item);
- } else {
- requests = requests.concat(getAllRequestsInFolderRecursively(item));
- }
- });
- }
-
- return requests;
-};
-
-module.exports = {
- sortCollection,
- sortFolder,
- findItemInCollection,
- getAllRequestsInFolderRecursively
-};
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index a74584461..4b4937d52 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -1,99 +1,65 @@
-const os = require('os');
-const fs = require('fs');
const qs = require('qs');
const https = require('https');
-const tls = require('tls');
const axios = require('axios');
const path = require('path');
const decomment = require('decomment');
-const Mustache = require('mustache');
+const fs = require('fs');
+const tls = require('tls');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
+const FormData = require('form-data');
const { ipcMain } = require('electron');
-const { isUndefined, isNull, each, get, compact, cloneDeep } = require('lodash');
+const { each, get, extend, cloneDeep, merge } = require('lodash');
+const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
-const prepareRequest = require('./prepare-request');
-const prepareCollectionRequest = require('./prepare-collection-request');
-const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
-const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
-const { uuid } = require('../../utils/common');
-const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
-const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
+const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
+const { addDigestInterceptor } = require('@usebruno/requests');
+const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
+const { prepareRequest } = require('./prepare-request');
+const interpolateVars = require('./interpolate-vars');
+const { makeAxiosInstance } = require('./axios-instance');
+const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
+const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common');
+const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
+const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
+const { createFormData } = require('../../utils/form-data');
+const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../../utils/collection');
+const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
-const { HttpProxyAgent } = require('http-proxy-agent');
-const { SocksProxyAgent } = require('socks-proxy-agent');
-const { makeAxiosInstance } = require('./axios-instance');
-const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
-const { addDigestInterceptor } = require('./digestauth-helper');
-const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
-const { chooseFileToSave, writeBinaryFile } = require('../../utils/filesystem');
-const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
-const {
- resolveOAuth2AuthorizationCodeAccessToken,
- transformClientCredentialsRequest,
- transformPasswordCredentialsRequest
-} = require('./oauth2-helper');
const Oauth2Store = require('../../store/oauth2');
-// override the default escape function to prevent escaping
-Mustache.escape = function (value) {
- return value;
-};
-
-const safeStringifyJSON = (data) => {
- try {
- return JSON.stringify(data);
- } catch (e) {
- return data;
- }
-};
-
-const safeParseJSON = (data) => {
- try {
- return JSON.parse(data);
- } catch (e) {
- return data;
- }
-};
-
-const getEnvVars = (environment = {}) => {
- const variables = environment.variables;
- if (!variables || !variables.length) {
- return {
- __name__: environment.name
- };
- }
-
- const envVars = {};
- each(variables, (variable) => {
- if (variable.enabled) {
- envVars[variable.name] = Mustache.escape(variable.value);
+const saveCookies = (url, headers) => {
+ if (preferencesUtil.shouldStoreCookies()) {
+ let setCookieHeaders = [];
+ if (headers['set-cookie']) {
+ setCookieHeaders = Array.isArray(headers['set-cookie'])
+ ? headers['set-cookie']
+ : [headers['set-cookie']];
+ for (let setCookieHeader of setCookieHeaders) {
+ if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
+ addCookieToJar(setCookieHeader, url);
+ }
+ }
}
- });
+ }
+}
- return {
- ...envVars,
- __name__: environment.name
- };
+const getJsSandboxRuntime = (collection) => {
+ const securityConfig = get(collection, 'securityConfig', {});
+ return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2';
};
-const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
-
-const configureRequest = async (
+const getCertsAndProxyConfig = async ({
collectionUid,
request,
envVars,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
collectionPath
-) => {
- if (!protocolRegex.test(request.url)) {
- request.url = `http://${request.url}`;
- }
-
+}) => {
/**
* @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors
* @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+
@@ -117,120 +83,172 @@ const configureRequest = async (
const brunoConfig = getBrunoConfig(collectionUid);
const interpolationOptions = {
envVars,
- collectionVariables,
+ runtimeVariables,
processEnvVars
};
// client certificate config
const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []);
+
for (let clientCert of clientCertConfig) {
- const domain = interpolateString(clientCert.domain, interpolationOptions);
-
- let certFilePath = interpolateString(clientCert.certFilePath, interpolationOptions);
- certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
-
- let keyFilePath = interpolateString(clientCert.keyFilePath, interpolationOptions);
- keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
-
- if (domain && certFilePath && keyFilePath) {
+ const domain = interpolateString(clientCert?.domain, interpolationOptions);
+ const type = clientCert?.type || 'cert';
+ if (domain) {
const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
-
if (request.url.match(hostRegex)) {
- try {
- httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
- } catch (err) {
- console.log('Error reading cert file', err);
- }
+ if (type === 'cert') {
+ try {
+ let certFilePath = interpolateString(clientCert?.certFilePath, interpolationOptions);
+ certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
+ let keyFilePath = interpolateString(clientCert?.keyFilePath, interpolationOptions);
+ keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
- try {
- httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
- } catch (err) {
- console.log('Error reading key file', err);
+ httpsAgentRequestFields['cert'] = fs.readFileSync(certFilePath);
+ httpsAgentRequestFields['key'] = fs.readFileSync(keyFilePath);
+ } catch (err) {
+ console.error('Error reading cert/key file', err);
+ throw new Error('Error reading cert/key file' + err);
+ }
+ } else if (type === 'pfx') {
+ try {
+ let pfxFilePath = interpolateString(clientCert?.pfxFilePath, interpolationOptions);
+ pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath);
+ httpsAgentRequestFields['pfx'] = fs.readFileSync(pfxFilePath);
+ } catch (err) {
+ console.error('Error reading pfx file', err);
+ throw new Error('Error reading pfx file' + err);
+ }
}
-
httpsAgentRequestFields['passphrase'] = interpolateString(clientCert.passphrase, interpolationOptions);
break;
}
}
}
- // proxy configuration
- let proxyConfig = get(brunoConfig, 'proxy', {});
- let proxyEnabled = get(proxyConfig, 'enabled', 'global');
- if (proxyEnabled === 'global') {
+ /**
+ * Proxy configuration
+ *
+ * Preferences proxyMode has three possible values: on, off, system
+ * Collection proxyMode has three possible values: true, false, global
+ *
+ * When collection proxyMode is true, it overrides the app-level proxy settings
+ * When collection proxyMode is false, it ignores the app-level proxy settings
+ * When collection proxyMode is global, it uses the app-level proxy settings
+ *
+ * Below logic calculates the proxyMode and proxyConfig to be used for the request
+ */
+ let proxyMode = 'off';
+ let proxyConfig = {};
+
+ const collectionProxyConfig = get(brunoConfig, 'proxy', {});
+ const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', 'global');
+ if (collectionProxyEnabled === true) {
+ proxyConfig = collectionProxyConfig;
+ proxyMode = 'on';
+ } else if (collectionProxyEnabled === 'global') {
proxyConfig = preferencesUtil.getGlobalProxyConfig();
- proxyEnabled = get(proxyConfig, 'enabled', false);
+ proxyMode = get(proxyConfig, 'mode', 'off');
}
- const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
- if (proxyEnabled === true && shouldProxy) {
- const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
- const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
- const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
- const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
- const socksEnabled = proxyProtocol.includes('socks');
+
+ return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
+}
- let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
- let proxyUri;
- if (proxyAuthEnabled) {
- const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
- const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
-
- proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
- } else {
- proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
- }
-
- if (socksEnabled) {
- request.httpsAgent = new SocksProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new SocksProxyAgent(proxyUri);
- } else {
- request.httpsAgent = new PatchedHttpsProxyAgent(
- proxyUri,
- Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
- );
- request.httpAgent = new HttpProxyAgent(proxyUri);
- }
- } else if (Object.keys(httpsAgentRequestFields).length > 0) {
- request.httpsAgent = new https.Agent({
- ...httpsAgentRequestFields
- });
+const configureRequest = async (
+ collectionUid,
+ request,
+ envVars,
+ runtimeVariables,
+ processEnvVars,
+ collectionPath
+) => {
+ const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
+ if (!protocolRegex.test(request.url)) {
+ request.url = `http://${request.url}`;
}
- const axiosInstance = makeAxiosInstance();
+ const certsAndProxyConfig = await getCertsAndProxyConfig({
+ collectionUid,
+ request,
+ envVars,
+ runtimeVariables,
+ processEnvVars,
+ collectionPath
+ });
+
+ let requestMaxRedirects = request.maxRedirects
+ request.maxRedirects = 0
+
+ // Set default value for requestMaxRedirects if not explicitly set
+ if (requestMaxRedirects === undefined) {
+ requestMaxRedirects = 5; // Default to 5 redirects
+ }
+
+ let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
+ let axiosInstance = makeAxiosInstance({
+ proxyMode,
+ proxyConfig,
+ requestMaxRedirects,
+ httpsAgentRequestFields,
+ interpolationOptions
+ });
+
+ if (request.ntlmConfig) {
+ axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
+ delete request.ntlmConfig;
+ }
if (request.oauth2) {
let requestCopy = cloneDeep(request);
- switch (request?.oauth2?.grantType) {
+ const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {};
+ let credentials, credentialsId;
+ switch (grantType) {
case 'authorization_code':
- interpolateVars(requestCopy, envVars, collectionVariables, processEnvVars);
- const { data: authorizationCodeData, url: authorizationCodeAccessTokenUrl } =
- await resolveOAuth2AuthorizationCodeAccessToken(requestCopy, collectionUid);
- request.method = 'POST';
- request.headers['content-type'] = 'application/x-www-form-urlencoded';
- request.data = authorizationCodeData;
- request.url = authorizationCodeAccessTokenUrl;
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig }));
+ request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
+ if (tokenPlacement == 'header') {
+ request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
+ }
+ else {
+ try {
+ const url = new URL(request.url);
+ url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
+ request.url = url?.toString();
+ }
+ catch(error) {}
+ }
break;
case 'client_credentials':
- interpolateVars(requestCopy, envVars, collectionVariables, processEnvVars);
- const { data: clientCredentialsData, url: clientCredentialsAccessTokenUrl } =
- await transformClientCredentialsRequest(requestCopy);
- request.method = 'POST';
- request.headers['content-type'] = 'application/x-www-form-urlencoded';
- request.data = clientCredentialsData;
- request.url = clientCredentialsAccessTokenUrl;
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
+ request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
+ if (tokenPlacement == 'header') {
+ request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
+ }
+ else {
+ try {
+ const url = new URL(request.url);
+ url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
+ request.url = url?.toString();
+ }
+ catch(error) {}
+ }
break;
case 'password':
- interpolateVars(requestCopy, envVars, collectionVariables, processEnvVars);
- const { data: passwordData, url: passwordAccessTokenUrl } = await transformPasswordCredentialsRequest(
- requestCopy
- );
- request.method = 'POST';
- request.headers['content-type'] = 'application/x-www-form-urlencoded';
- request.data = passwordData;
- request.url = passwordAccessTokenUrl;
+ interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
+ ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig }));
+ request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
+ if (tokenPlacement == 'header') {
+ request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
+ }
+ else {
+ try {
+ const url = new URL(request.url);
+ url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
+ request.url = url?.toString();
+ }
+ catch(error) {}
+ }
break;
}
}
@@ -251,29 +269,123 @@ const configureRequest = async (
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
- request.headers['cookie'] = cookieString;
+ const existingCookieHeaderName = Object.keys(request.headers).find(
+ name => name.toLowerCase() === 'cookie'
+ );
+ const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
+
+ // Helper function to parse cookies into an object
+ const parseCookies = (str) => str.split(';').reduce((cookies, cookie) => {
+ const [name, ...rest] = cookie.split('=');
+ if (name && name.trim()) {
+ cookies[name.trim()] = rest.join('=').trim();
+ }
+ return cookies;
+ }, {});
+
+ const mergedCookies = {
+ ...parseCookies(existingCookieString),
+ ...parseCookies(cookieString),
+ };
+
+ const combinedCookieString = Object.entries(mergedCookies)
+ .map(([name, value]) => `${name}=${value}`)
+ .join('; ');
+
+ request.headers[existingCookieHeaderName || 'Cookie'] = combinedCookieString;
}
}
+
+ // Add API key to the URL
+ if (request.apiKeyAuthValueForQueryParams && request.apiKeyAuthValueForQueryParams.placement === 'queryparams') {
+ const urlObj = new URL(request.url);
+
+ // Interpolate key and value as they can be variables before adding to the URL.
+ const key = interpolateString(request.apiKeyAuthValueForQueryParams.key, interpolationOptions);
+ const value = interpolateString(request.apiKeyAuthValueForQueryParams.value, interpolationOptions);
+
+ urlObj.searchParams.set(key, value);
+ request.url = urlObj.toString();
+ }
+
+ // Remove pathParams, already in URL (Issue #2439)
+ delete request.pathParams;
+
+ // Remove apiKeyAuthValueForQueryParams, already interpolated and added to URL
+ delete request.apiKeyAuthValueForQueryParams;
+
return axiosInstance;
};
-const parseDataFromResponse = (response) => {
- const dataBuffer = Buffer.from(response.data);
- // Parse the charset from content type: https://stackoverflow.com/a/33192813
- const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
- // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
- const charsetValue = charsetMatch?.[1];
- // Overwrite the original data for backwards compatibility
- let data = dataBuffer.toString(charsetValue || 'utf-8');
- // Try to parse response to JSON, this can quietly fail
+const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, collection) => {
try {
- // Filter out ZWNBSP character
- // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
- data = data.replace(/^\uFEFF/, '');
- data = JSON.parse(data);
- } catch {}
+ const requestTreePath = getTreePathFromCollectionToItem(collection, _request);
+ // Create a clone of the request to avoid mutating the original
+ const resolvedRequest = cloneDeep(_request);
+ // mergeVars modifies the request in place, but we'll assign it to ensure consistency
+ mergeVars(collection, resolvedRequest, requestTreePath);
+ const envVars = getEnvVars(environment);
- return { data, dataBuffer };
+ const globalEnvironmentVars = collection.globalEnvironmentVariables;
+ const folderVars = resolvedRequest.folderVariables;
+ const requestVariables = resolvedRequest.requestVariables;
+ const collectionVariables = resolvedRequest.collectionVariables;
+ const runtimeVars = collection.runtimeVariables;
+
+ // Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars
+ const resolvedVars = merge(
+ {},
+ globalEnvironmentVars,
+ collectionVariables,
+ envVars,
+ folderVars,
+ requestVariables,
+ runtimeVars
+ );
+
+ const collectionRoot = get(collection, 'root', {});
+ const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot);
+
+ request.timeout = preferencesUtil.getRequestTimeout();
+
+ if (!preferencesUtil.shouldVerifyTls()) {
+ request.httpsAgent = new https.Agent({
+ rejectUnauthorized: false
+ });
+ }
+
+ const collectionPath = collection.pathname;
+ const processEnvVars = getProcessEnvVars(collection.uid);
+
+ const axiosInstance = await configureRequest(
+ collection.uid,
+ request,
+ envVars,
+ collection.runtimeVariables,
+ processEnvVars,
+ collectionPath
+ );
+
+ const response = await axiosInstance(request);
+
+ return {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ data: response.data
+ };
+ } catch (error) {
+ if (error.response) {
+ return {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ headers: error.response.headers,
+ data: error.response.data
+ };
+ }
+
+ return Promise.reject(error);
+ }
};
const registerNetworkIpc = (mainWindow) => {
@@ -291,61 +403,48 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
- collectionRoot,
+ collection,
collectionUid,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname
) => {
- // run pre-request vars
- const preRequestVars = get(request, 'vars.req', []);
- if (preRequestVars?.length) {
- const varsRuntime = new VarsRuntime();
- const result = varsRuntime.runPreRequestVars(
- preRequestVars,
- request,
- envVars,
- collectionVariables,
- collectionPath,
- processEnvVars
- );
-
- if (result) {
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: result.envVariables,
- collectionVariables: result.collectionVariables,
- requestUid,
- collectionUid
- });
- }
- }
-
// run pre-request script
let scriptResult;
- const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(os.EOL);
+ const collectionName = collection?.name
+ const requestScript = get(request, 'script.req');
if (requestScript?.length) {
- const scriptRuntime = new ScriptRuntime();
+ const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runRequestScript(
decomment(requestScript),
request,
envVars,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: scriptResult.envVariables,
- collectionVariables: scriptResult.collectionVariables,
+ runtimeVariables: scriptResult.runtimeVariables,
requestUid,
collectionUid
});
+
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
+ });
+
+ collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
// interpolate variables inside request
- interpolateVars(request, envVars, collectionVariables, processEnvVars);
+ interpolateVars(request, envVars, runtimeVariables, processEnvVars);
// if this is a graphql request, parse the variables, only after interpolation
// https://github.com/usebruno/bruno/issues/884
@@ -358,6 +457,14 @@ const registerNetworkIpc = (mainWindow) => {
request.data = qs.stringify(request.data);
}
+ if (request.headers['content-type'] === 'multipart/form-data') {
+ if (!(request.data instanceof FormData)) {
+ let form = createFormData(request.data, collectionPath);
+ request.data = form;
+ extend(request.headers, form.getHeaders());
+ }
+ }
+
return scriptResult;
};
@@ -367,22 +474,23 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
- collectionRoot,
+ collection,
collectionUid,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname
) => {
// run post-response vars
const postResponseVars = get(request, 'vars.res', []);
if (postResponseVars?.length) {
- const varsRuntime = new VarsRuntime();
+ const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
const result = varsRuntime.runPostResponseVars(
postResponseVars,
request,
response,
envVars,
- collectionVariables,
+ runtimeVariables,
collectionPath,
processEnvVars
);
@@ -390,50 +498,84 @@ const registerNetworkIpc = (mainWindow) => {
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
- collectionVariables: result.collectionVariables,
+ runtimeVariables: result.runtimeVariables,
requestUid,
collectionUid
});
+
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: result.globalEnvironmentVariables
+ });
+
+ collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
}
+
+ if (result?.error) {
+ mainWindow.webContents.send('main:display-error', result.error);
+ }
+
+ collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
}
// run post-response script
+ const responseScript = get(request, 'script.res');
let scriptResult;
- const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join(
- os.EOL
- );
+ const collectionName = collection?.name
if (responseScript?.length) {
- const scriptRuntime = new ScriptRuntime();
+ const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runResponseScript(
decomment(responseScript),
request,
response,
envVars,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: scriptResult.envVariables,
- collectionVariables: scriptResult.collectionVariables,
+ runtimeVariables: scriptResult.runtimeVariables,
requestUid,
collectionUid
});
+
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
+ });
+
+ collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
return scriptResult;
};
- // handler for sending http request
- ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => {
+ const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
- const requestUid = uuid();
+ // requestUid is passed when a request is triggered; defaults to uuid() if not provided (e.g., bru.runRequest())
+ const requestUid = item.requestUid || uuid();
- mainWindow.webContents.send('main:run-request-event', {
+ const runRequestByItemPathname = async (relativeItemPathname) => {
+ return new Promise(async (resolve, reject) => {
+ let itemPathname = path.join(collection?.pathname, relativeItemPathname);
+ if (itemPathname && !itemPathname?.endsWith('.bru')) {
+ itemPathname = `${itemPathname}.bru`;
+ }
+ const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
+ if(_item) {
+ const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
+ resolve(res);
+ }
+ reject(`bru.runRequest: invalid request path - ${itemPathname}`);
+ });
+ }
+
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-queued',
requestUid,
collectionUid,
@@ -441,55 +583,98 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
- const collectionRoot = get(collection, 'root', {});
- const _request = item.draft ? item.draft.request : item.request;
- const request = prepareRequest(_request, collectionRoot, collectionPath);
- const envVars = getEnvVars(environment);
- const processEnvVars = getProcessEnvVars(collectionUid);
+ const abortController = new AbortController();
+ const request = await prepareRequest(item, collection, abortController);
+ request.__bruno__executionMode = 'standalone';
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
+ scriptingConfig.runtime = getJsSandboxRuntime(collection);
try {
- const controller = new AbortController();
- request.signal = controller.signal;
- saveCancelToken(cancelTokenUid, controller);
+ request.signal = abortController.signal;
+ saveCancelToken(cancelTokenUid, abortController);
- await runPreRequest(
- request,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
+
+ try {
+ const preRequestScriptResult = await runPreRequest(
+ request,
+ requestUid,
+ envVars,
+ collectionPath,
+ collection,
+ collectionUid,
+ runtimeVariables,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname
+ );
+ if (preRequestScriptResult?.results) {
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'test-results-pre-request',
+ results: preRequestScriptResult.results,
+ itemUid: item.uid,
+ requestUid,
+ collectionUid
+ });
+ }
+
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
+ type: 'pre-request-script-execution',
+ requestUid,
+ collectionUid,
+ itemUid: item.uid,
+ errorMessage: null,
+ });
+
+ } catch (error) {
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
+ type: 'pre-request-script-execution',
+ requestUid,
+ collectionUid,
+ itemUid: item.uid,
+ errorMessage: error?.message || 'An error occurred in pre-request script',
+ });
+ return Promise.reject(error);
+ }
const axiosInstance = await configureRequest(
collectionUid,
request,
envVars,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
collectionPath
);
- mainWindow.webContents.send('main:run-request-event', {
+ const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
+ let requestSent = {
+ url: request.url,
+ method: request.method,
+ headers: request.headers,
+ data: requestData,
+ dataBuffer: requestDataBuffer
+ }
+
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-sent',
- requestSent: {
- url: request.url,
- method: request.method,
- headers: request.headers,
- data: safeParseJSON(safeStringifyJSON(request.data)),
- timestamp: Date.now()
- },
+ requestSent,
collectionUid,
itemUid: item.uid,
requestUid,
cancelTokenUid
});
+ if (request?.oauth2Credentials) {
+ mainWindow.webContents.send('main:credentials-update', {
+ credentials: request?.oauth2Credentials?.credentials,
+ url: request?.oauth2Credentials?.url,
+ collectionUid,
+ credentialsId: request?.oauth2Credentials?.credentialsId,
+ ...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
+ debugInfo: request?.oauth2Credentials?.debugInfo,
+ });
+ }
+
let response, responseTime;
try {
/** @type {import('axios').AxiosResponse} */
@@ -503,9 +688,14 @@ const registerNetworkIpc = (mainWindow) => {
// if it's a cancel request, don't continue
if (axios.isCancel(error)) {
- let error = new Error('Request cancelled');
- error.isCancel = true;
- return Promise.reject(error);
+ // we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
+ // timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
+ return {
+ statusText: 'REQUEST_CANCELLED',
+ isCancel: true,
+ error: 'REQUEST_CANCELLED',
+ timeline: error.timeline
+ };
}
if (error?.response) {
@@ -516,30 +706,26 @@ const registerNetworkIpc = (mainWindow) => {
response.headers.delete('request-duration');
} else {
// if it's not a network error, don't continue
- return Promise.reject(error);
+ // we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
+ // timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
+ return {
+ statusText: error.statusText,
+ error: error.message || 'Error occured while executing the request!',
+ timeline: error.timeline
+ }
}
}
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
- const { data, dataBuffer } = parseDataFromResponse(response);
+ const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
response.responseTime = responseTime;
// save cookies
if (preferencesUtil.shouldStoreCookies()) {
- let setCookieHeaders = [];
- if (response.headers['set-cookie']) {
- setCookieHeaders = Array.isArray(response.headers['set-cookie'])
- ? response.headers['set-cookie']
- : [response.headers['set-cookie']];
- for (let setCookieHeader of setCookieHeaders) {
- if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
- addCookieToJar(setCookieHeader, request.url);
- }
- }
- }
+ saveCookies(request.url, response.headers);
}
// send domain cookies to renderer
@@ -547,33 +733,66 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
- await runPostResponse(
- request,
- response,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
+ try {
+ const postResponseScriptResult = await runPostResponse(
+ request,
+ response,
+ requestUid,
+ envVars,
+ collectionPath,
+ collection,
+ collectionUid,
+ runtimeVariables,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname
+ );
+
+ if (postResponseScriptResult?.results) {
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'test-results-post-response',
+ results: postResponseScriptResult.results,
+ itemUid: item.uid,
+ requestUid,
+ collectionUid
+ });
+ }
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
+ type: 'post-response-script-execution',
+ requestUid,
+ collectionUid,
+ errorMessage: null,
+ itemUid: item.uid,
+ });
+ } catch (error) {
+ console.error('Post-response script error:', error);
+
+ // Format a more readable error message
+ const errorMessage = error?.message || 'An error occurred in post-response script';
+
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
+ type: 'post-response-script-execution',
+ requestUid,
+ errorMessage,
+ collectionUid,
+ itemUid: item.uid,
+ });
+ }
// run assertions
const assertions = get(request, 'assertions');
if (assertions) {
- const assertRuntime = new AssertRuntime();
+ const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(
assertions,
request,
response,
envVars,
- collectionVariables,
- collectionPath
+ runtimeVariables,
+ processEnvVars
);
- mainWindow.webContents.send('main:run-request-event', {
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'assertion-results',
results: results,
itemUid: item.uid,
@@ -582,26 +801,25 @@ const registerNetworkIpc = (mainWindow) => {
});
}
- // run tests
- const testFile = compact([
- get(collectionRoot, 'request.tests'),
- item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
- ]).join(os.EOL);
+ const testFile = get(request, 'tests');
+ const collectionName = collection?.name
if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
+ const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
);
- mainWindow.webContents.send('main:run-request-event', {
+ !runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
results: testResults.results,
itemUid: item.uid,
@@ -611,10 +829,16 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
- collectionVariables: testResults.collectionVariables,
+ runtimeVariables: testResults.runtimeVariables,
requestUid,
collectionUid
});
+
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: testResults.globalEnvironmentVariables
+ });
+
+ collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
}
return {
@@ -624,93 +848,35 @@ const registerNetworkIpc = (mainWindow) => {
data: response.data,
dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
- duration: responseTime ?? 0
+ duration: responseTime ?? 0,
+ timeline: response.timeline
};
} catch (error) {
deleteCancelToken(cancelTokenUid);
- return Promise.reject(error);
- }
- });
-
- ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, collectionVariables) => {
- try {
- const collectionUid = collection.uid;
- const collectionPath = collection.pathname;
- const requestUid = uuid();
-
- const collectionRoot = get(collection, 'root', {});
- const _request = collectionRoot?.request;
- const request = prepareCollectionRequest(_request, collectionRoot, collectionPath);
- const envVars = getEnvVars(environment);
- const processEnvVars = getProcessEnvVars(collectionUid);
- const brunoConfig = getBrunoConfig(collectionUid);
- const scriptingConfig = get(brunoConfig, 'scripts', {});
-
- await runPreRequest(
- request,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
-
- interpolateVars(request, envVars, collection.collectionVariables, processEnvVars);
- const axiosInstance = await configureRequest(
- collection.uid,
- request,
- envVars,
- collection.collectionVariables,
- processEnvVars,
- collectionPath
- );
-
- try {
- response = await axiosInstance(request);
- } catch (error) {
- if (error?.response) {
- response = error.response;
- } else {
- return Promise.reject(error);
- }
- }
-
- const { data } = parseDataFromResponse(response);
- response.data = data;
-
- await runPostResponse(
- request,
- response,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
-
+ // we are not rejecting the promise here and instead returning a response object with `error` which is handled in the `send-http-request` invocation
+ // timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- data: response.data
+ status: error?.status,
+ error: error?.message || 'Error occured while executing the request!',
+ timeline: error?.timeline
};
- } catch (error) {
- return Promise.reject(error);
}
+ }
+
+ // handler for sending http request
+ ipcMain.handle('send-http-request', async (event, 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('clear-oauth2-cache', async (event, uid) => {
+ ipcMain.handle('clear-oauth2-cache', async (event, uid, url, credentialsId) => {
return new Promise((resolve, reject) => {
try {
const oauth2Store = new Oauth2Store();
- oauth2Store.clearSessionIdOfCollection(uid);
+ oauth2Store.clearSessionIdOfCollection({ collectionUid: uid, url, credentialsId });
resolve();
} catch (err) {
reject(new Error('Could not clear oauth2 cache'));
@@ -730,98 +896,41 @@ const registerNetworkIpc = (mainWindow) => {
});
});
- ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment, request, collection) => {
- try {
- const envVars = getEnvVars(environment);
- const collectionRoot = get(collection, 'root', {});
- const preparedRequest = prepareGqlIntrospectionRequest(endpoint, envVars, request, collectionRoot);
-
- request.timeout = preferencesUtil.getRequestTimeout();
-
- if (!preferencesUtil.shouldVerifyTls()) {
- request.httpsAgent = new https.Agent({
- rejectUnauthorized: false
- });
- }
-
- const requestUid = uuid();
- const collectionPath = collection.pathname;
- const collectionUid = collection.uid;
- const collectionVariables = collection.collectionVariables;
- const processEnvVars = getProcessEnvVars(collectionUid);
- const brunoConfig = getBrunoConfig(collection.uid);
- const scriptingConfig = get(brunoConfig, 'scripts', {});
-
- await runPreRequest(
- request,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
-
- interpolateVars(preparedRequest, envVars, collection.collectionVariables, processEnvVars);
- const axiosInstance = await configureRequest(
- collection.uid,
- preparedRequest,
- envVars,
- collection.collectionVariables,
- processEnvVars,
- collectionPath
- );
- const response = await axiosInstance(preparedRequest);
-
- await runPostResponse(
- request,
- response,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
-
- return {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- data: response.data
- };
- } catch (error) {
- if (error.response) {
- return {
- status: error.response.status,
- statusText: error.response.statusText,
- headers: error.response.headers,
- data: error.response.data
- };
- }
-
- return Promise.reject(error);
- }
- });
+ // handler for fetch-gql-schema
+ ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler)
ipcMain.handle(
'renderer:run-collection-folder',
- async (event, folder, collection, environment, collectionVariables, recursive) => {
+ async (event, folder, collection, environment, runtimeVariables, recursive, delay) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
const cancelTokenUid = uuid();
const brunoConfig = getBrunoConfig(collectionUid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
- const collectionRoot = get(collection, 'root', {});
+ scriptingConfig.runtime = getJsSandboxRuntime(collection);
+ const envVars = getEnvVars(environment);
+ const processEnvVars = getProcessEnvVars(collectionUid);
+ let stopRunnerExecution = false;
const abortController = new AbortController();
saveCancelToken(cancelTokenUid, abortController);
+ const runRequestByItemPathname = async (relativeItemPathname) => {
+ return new Promise(async (resolve, reject) => {
+ let itemPathname = path.join(collection?.pathname, relativeItemPathname);
+ if (itemPathname && !itemPathname?.endsWith('.bru')) {
+ itemPathname = `${itemPathname}.bru`;
+ }
+ const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
+ if(_item) {
+ const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
+ resolve(res);
+ }
+ reject(`bru.runRequest: invalid request path - ${itemPathname}`);
+ });
+ }
+
if (!folder) {
folder = collection;
}
@@ -835,7 +944,6 @@ const registerNetworkIpc = (mainWindow) => {
});
try {
- const envVars = getEnvVars(environment);
let folderRequests = [];
if (recursive) {
@@ -864,7 +972,9 @@ const registerNetworkIpc = (mainWindow) => {
throw error;
}
- const item = folderRequests[currentRequestIndex];
+ stopRunnerExecution = false;
+
+ const item = cloneDeep(folderRequests[currentRequestIndex]);
let nextRequestName;
const itemUid = item.uid;
const eventData = {
@@ -881,10 +991,10 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
- const _request = item.draft ? item.draft.request : item.request;
- const request = prepareRequest(_request, collectionRoot, collectionPath);
+ const request = await prepareRequest(item, collection, abortController);
+ request.__bruno__executionMode = 'runner';
+
const requestUid = uuid();
- const processEnvVars = getProcessEnvVars(collectionUid);
try {
const preRequestScriptResult = await runPreRequest(
@@ -892,28 +1002,63 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
envVars,
collectionPath,
- collectionRoot,
+ collection,
collectionUid,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname
);
if (preRequestScriptResult?.nextRequestName !== undefined) {
nextRequestName = preRequestScriptResult.nextRequestName;
}
+ if (preRequestScriptResult?.stopExecution) {
+ stopRunnerExecution = true;
+ }
+
+ // Send pre-request test results if available
+ if (preRequestScriptResult?.results) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'test-results-pre-request',
+ preRequestTestResults: preRequestScriptResult.results,
+ ...eventData
+ });
+ }
+
+ if (preRequestScriptResult?.skipRequest) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'runner-request-skipped',
+ error: 'Request has been skipped from pre-request script',
+ responseReceived: {
+ status: 'skipped',
+ statusText: 'request skipped via pre-request script',
+ data: null,
+ responseTime: 0,
+ headers: null
+ },
+ ...eventData
+ });
+ currentRequestIndex++;
+ continue;
+ }
+
+ const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
+ let requestSent = {
+ url: request.url,
+ method: request.method,
+ headers: request.headers,
+ data: requestData,
+ dataBuffer: requestDataBuffer
+ }
+
// todo:
// i have no clue why electron can't send the request object
// without safeParseJSON(safeStringifyJSON(request.data))
mainWindow.webContents.send('main:run-folder-event', {
type: 'request-sent',
- requestSent: {
- url: request.url,
- method: request.method,
- headers: request.headers,
- data: safeParseJSON(safeStringifyJSON(request.data))
- },
+ requestSent,
...eventData
});
@@ -922,22 +1067,53 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
request,
envVars,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
collectionPath
);
+ if (request?.oauth2Credentials) {
+ mainWindow.webContents.send('main:credentials-update', {
+ credentials: request?.oauth2Credentials?.credentials,
+ url: request?.oauth2Credentials?.url,
+ collectionUid,
+ credentialsId: request?.oauth2Credentials?.credentialsId
+ });
+ }
+
timeStart = Date.now();
let response, responseTime;
try {
+ if (delay && !Number.isNaN(delay) && delay > 0) {
+ const delayPromise = new Promise((resolve) => setTimeout(resolve, delay));
+
+ const cancellationPromise = new Promise((_, reject) => {
+ abortController.signal.addEventListener('abort', () => {
+ reject(new Error('Cancelled'));
+ });
+ });
+
+ await Promise.race([delayPromise, cancellationPromise]);
+ }
+
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
timeEnd = Date.now();
- const { data, dataBuffer } = parseDataFromResponse(response);
+ const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
response.responseTime = response.headers.get('request-duration');
+ // save cookies
+ if (preferencesUtil.shouldStoreCookies()) {
+ saveCookies(request.url, response.headers);
+ }
+
+ // send domain cookies to renderer
+ const domainsWithCookies = await getDomainsWithCookies();
+
+ mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
+
mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received',
responseReceived: {
@@ -982,34 +1158,48 @@ const registerNetworkIpc = (mainWindow) => {
}
}
- const postRequestScriptResult = await runPostResponse(
+ const postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
- collectionRoot,
+ collection,
collectionUid,
- collectionVariables,
+ runtimeVariables,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname
);
- if (postRequestScriptResult?.nextRequestName !== undefined) {
- nextRequestName = postRequestScriptResult.nextRequestName;
+ if (postResponseScriptResult?.nextRequestName !== undefined) {
+ nextRequestName = postResponseScriptResult.nextRequestName;
+ }
+
+ if (postResponseScriptResult?.stopExecution) {
+ stopRunnerExecution = true;
+ }
+
+ // Send post-response test results if available
+ if (postResponseScriptResult?.results) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'test-results-post-response',
+ postResponseTestResults: postResponseScriptResult.results,
+ ...eventData
+ });
}
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions) {
- const assertRuntime = new AssertRuntime();
+ const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(
assertions,
request,
response,
envVars,
- collectionVariables,
- collectionPath
+ runtimeVariables,
+ processEnvVars
);
mainWindow.webContents.send('main:run-folder-event', {
@@ -1020,25 +1210,28 @@ const registerNetworkIpc = (mainWindow) => {
});
}
- // run tests
- const testFile = compact([
- get(collectionRoot, 'request.tests'),
- item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
- ]).join(os.EOL);
+ const testFile = get(request, 'tests');
+ const collectionName = collection?.name
if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
+ const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
);
+ if (testResults?.nextRequestName !== undefined) {
+ nextRequestName = testResults.nextRequestName;
+ }
+
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: testResults.results,
@@ -1047,9 +1240,15 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
- collectionVariables: testResults.collectionVariables,
+ runtimeVariables: testResults.runtimeVariables,
collectionUid
});
+
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: testResults.globalEnvironmentVariables
+ });
+
+ collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
@@ -1059,6 +1258,18 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
}
+
+ if (stopRunnerExecution) {
+ deleteCancelToken(cancelTokenUid);
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'testrun-ended',
+ collectionUid,
+ folderUid,
+ statusText: 'collection run was terminated!'
+ });
+ break;
+ }
+
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {
@@ -1086,6 +1297,7 @@ const registerNetworkIpc = (mainWindow) => {
folderUid
});
} catch (error) {
+ console.log("error", error);
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
@@ -1116,7 +1328,7 @@ const registerNetworkIpc = (mainWindow) => {
try {
const disposition = contentDispositionParser.parse(contentDisposition);
return disposition && disposition.parameters['filename'];
- } catch (error) {}
+ } catch (error) { }
};
const getFileNameFromUrlPath = () => {
@@ -1132,12 +1344,28 @@ const registerNetworkIpc = (mainWindow) => {
return `response.${extension}`;
};
- const fileName =
- getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader();
+ const getEncodingFormat = () => {
+ const contentType = getHeaderValue('content-type');
+ const extension = mime.extension(contentType) || 'txt';
+ return ['json', 'xml', 'html', 'yml', 'yaml', 'txt'].includes(extension) ? 'utf-8' : 'base64';
+ };
+ const determineFileName = () => {
+ return (
+ getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader()
+ );
+ };
+
+ const fileName = determineFileName();
const filePath = await chooseFileToSave(mainWindow, fileName);
if (filePath) {
- await writeBinaryFile(filePath, Buffer.from(response.dataBuffer, 'base64'));
+ const encoding = getEncodingFormat();
+ const data = Buffer.from(response.dataBuffer, 'base64')
+ if (encoding === 'utf-8') {
+ await writeFile(filePath, data);
+ } else {
+ await writeFile(filePath, data, true);
+ }
}
} catch (error) {
return Promise.reject(error);
@@ -1147,3 +1375,5 @@ const registerNetworkIpc = (mainWindow) => {
module.exports = registerNetworkIpc;
module.exports.configureRequest = configureRequest;
+module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;
+module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler;
diff --git a/packages/bruno-electron/src/ipc/network/interpolate-string.js b/packages/bruno-electron/src/ipc/network/interpolate-string.js
index 052041670..e210be339 100644
--- a/packages/bruno-electron/src/ipc/network/interpolate-string.js
+++ b/packages/bruno-electron/src/ipc/network/interpolate-string.js
@@ -1,13 +1,13 @@
const { forOwn, cloneDeep } = require('lodash');
const { interpolate } = require('@usebruno/common');
-const interpolateString = (str, { envVars, collectionVariables, processEnvVars }) => {
+const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
processEnvVars = processEnvVars || {};
- collectionVariables = collectionVariables || {};
+ runtimeVariables = runtimeVariables || {};
// we clone envVars because we don't want to modify the original object
envVars = envVars ? cloneDeep(envVars) : {};
@@ -24,10 +24,10 @@ const interpolateString = (str, { envVars, collectionVariables, processEnvVars }
});
});
- // collectionVariables take precedence over envVars
+ // runtimeVariables take precedence over envVars
const combinedVars = {
...envVars,
- ...collectionVariables,
+ ...runtimeVariables,
process: {
env: {
...processEnvVars
diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
index 2139194a2..78c5454ed 100644
--- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js
+++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
@@ -1,5 +1,6 @@
const { interpolate } = require('@usebruno/common');
-const { each, forOwn, cloneDeep } = require('lodash');
+const { each, forOwn, cloneDeep, find } = require('lodash');
+const FormData = require('form-data');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -12,14 +13,19 @@ const getContentType = (headers = {}) => {
return contentType;
};
-const interpolateVars = (request, envVars = {}, collectionVariables = {}, processEnvVars = {}) => {
+const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
// we clone envVars because we don't want to modify the original object
- envVars = cloneDeep(envVars);
+ envVariables = cloneDeep(envVariables);
// envVars can inturn have values as {{process.env.VAR_NAME}}
// so we need to interpolate envVars first with processEnvVars
- forOwn(envVars, (value, key) => {
- envVars[key] = interpolate(value, {
+ forOwn(envVariables, (value, key) => {
+ envVariables[key] = interpolate(value, {
process: {
env: {
...processEnvVars
@@ -28,15 +34,20 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
});
});
- const _interpolate = (str) => {
+ const _interpolate = (str, { escapeJSONStrings } = {}) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
- // collectionVariables take precedence over envVars
+ // runtimeVariables take precedence over envVars
const combinedVars = {
- ...envVars,
+ ...globalEnvironmentVariables,
...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...oauth2CredentialVariables,
+ ...runtimeVariables,
process: {
env: {
...processEnvVars
@@ -44,7 +55,9 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
}
};
- return interpolate(str, combinedVars);
+ return interpolate(str, combinedVars, {
+ escapeJSONStrings
+ });
};
request.url = _interpolate(request.url);
@@ -56,36 +69,82 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
const contentType = getContentType(request.headers);
- if (contentType.includes('json')) {
- if (typeof request.data === 'object') {
- try {
- let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
- request.data = JSON.parse(parsed);
- } catch (err) {}
- }
-
+ /*
+ We explicitly avoid interpolating buffer values because the file content is read as a buffer object in raw body mode.
+ Even if the selected file's content type is JSON, this prevents the buffer object from being interpolated.
+ */
+ if (contentType.includes('json') && !Buffer.isBuffer(request.data)) {
if (typeof request.data === 'string') {
if (request.data.length) {
- request.data = _interpolate(request.data);
+ request.data = _interpolate(request.data, {
+ escapeJSONStrings: true
+ });
}
+ } else if (typeof request.data === 'object') {
+ try {
+ const jsonDoc = JSON.stringify(request.data);
+ const parsed = _interpolate(jsonDoc, {
+ escapeJSONStrings: true
+ });
+ request.data = JSON.parse(parsed);
+ } catch (err) {}
}
} else if (contentType === 'application/x-www-form-urlencoded') {
if (typeof request.data === 'object') {
try {
- let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
- request.data = JSON.parse(parsed);
+ forOwn(request?.data, (value, key) => {
+ request.data[key] = _interpolate(value);
+ });
+ } catch (err) {}
+ }
+ } else if (contentType === 'multipart/form-data') {
+ if (Array.isArray(request?.data) && !(request.data instanceof FormData)) {
+ try {
+ request.data = request?.data?.map(d => ({
+ ...d,
+ value: _interpolate(d?.value)
+ }));
} catch (err) {}
}
} else {
request.data = _interpolate(request.data);
}
- each(request.params, (param) => {
+ each(request.pathParams, (param) => {
param.value = _interpolate(param.value);
});
+ if (request?.pathParams?.length) {
+ let url = request.url;
+
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = `http://${url}`;
+ }
+
+ try {
+ url = new URL(url);
+ } catch (e) {
+ throw { message: 'Invalid URL format', originalError: e.message };
+ }
+
+ const urlPathnameInterpolatedWithPathParams = url.pathname
+ .split('/')
+ .filter((path) => path !== '')
+ .map((path) => {
+ if (path[0] !== ':') {
+ return '/' + path;
+ } else {
+ const name = path.slice(1);
+ const existingPathParam = request.pathParams.find((param) => param.type === 'path' && param.name === name);
+ return existingPathParam ? '/' + existingPathParam.value : '';
+ }
+ })
+ .join('');
+
+ const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
+ request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + url.search;
+ }
+
if (request.proxy) {
request.proxy.protocol = _interpolate(request.proxy.protocol);
request.proxy.hostname = _interpolate(request.proxy.hostname);
@@ -100,61 +159,64 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
// todo: we have things happening in two places w.r.t basic auth
// need to refactor this in the future
// the request.auth (basic auth) object gets set inside the prepare-request.js file
- if (request.auth) {
- const username = _interpolate(request.auth.username) || '';
- const password = _interpolate(request.auth.password) || '';
+ if (request.basicAuth) {
+ const username = _interpolate(request.basicAuth.username) || '';
+ const password = _interpolate(request.basicAuth.password) || '';
// use auth header based approach and delete the request.auth object
request.headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
- delete request.auth;
+ delete request.basicAuth;
}
if (request?.oauth2?.grantType) {
let username, password, scope, clientId, clientSecret;
switch (request.oauth2.grantType) {
case 'password':
- username = _interpolate(request.oauth2.username) || '';
- password = _interpolate(request.oauth2.password) || '';
- clientId = _interpolate(request.oauth2.clientId) || '';
- clientSecret = _interpolate(request.oauth2.clientSecret) || '';
- scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
- request.oauth2.username = username;
- request.oauth2.password = password;
- request.oauth2.clientId = clientId;
- request.oauth2.clientSecret = clientSecret;
- request.oauth2.scope = scope;
- request.data = {
- grant_type: 'password',
- username,
- password,
- client_id: clientId,
- client_secret: clientSecret,
- scope
- };
+ request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
+ request.oauth2.username = _interpolate(request.oauth2.username) || '';
+ request.oauth2.password = _interpolate(request.oauth2.password) || '';
+ request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
+ request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
+ request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
+ request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
+ request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
+ request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
+ request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
+ request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
+ request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken);
+ request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken);
break;
case 'authorization_code':
request.oauth2.callbackUrl = _interpolate(request.oauth2.callbackUrl) || '';
request.oauth2.authorizationUrl = _interpolate(request.oauth2.authorizationUrl) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
+ request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
+ request.oauth2.state = _interpolate(request.oauth2.state) || '';
request.oauth2.pkce = _interpolate(request.oauth2.pkce) || false;
+ request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
+ request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
+ request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
+ request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
+ request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
+ request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken);
+ request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken);
break;
case 'client_credentials':
- clientId = _interpolate(request.oauth2.clientId) || '';
- clientSecret = _interpolate(request.oauth2.clientSecret) || '';
- scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
- request.oauth2.clientId = clientId;
- request.oauth2.clientSecret = clientSecret;
- request.oauth2.scope = scope;
- request.data = {
- grant_type: 'client_credentials',
- client_id: clientId,
- client_secret: clientSecret,
- scope
- };
+ request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
+ request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
+ request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
+ request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
+ request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
+ request.oauth2.credentialsId = _interpolate(request.oauth2.credentialsId) || '';
+ request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
+ request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
+ request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
+ request.oauth2.autoFetchToken = _interpolate(request.oauth2.autoFetchToken);
+ request.oauth2.autoRefreshToken = _interpolate(request.oauth2.autoRefreshToken);
break;
default:
break;
@@ -177,6 +239,21 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
request.digestConfig.password = _interpolate(request.digestConfig.password) || '';
}
+ // interpolate vars for wsse auth
+ if (request.wsse) {
+ request.wsse.username = _interpolate(request.wsse.username) || '';
+ request.wsse.password = _interpolate(request.wsse.password) || '';
+ }
+
+ // interpolate vars for ntlmConfig auth
+ if (request.ntlmConfig) {
+ request.ntlmConfig.username = _interpolate(request.ntlmConfig.username) || '';
+ request.ntlmConfig.password = _interpolate(request.ntlmConfig.password) || '';
+ request.ntlmConfig.domain = _interpolate(request.ntlmConfig.domain) || '';
+ }
+
+ if(request?.auth) delete request.auth;
+
return request;
};
diff --git a/packages/bruno-electron/src/ipc/network/oauth2-helper.js b/packages/bruno-electron/src/ipc/network/oauth2-helper.js
deleted file mode 100644
index e254e8c74..000000000
--- a/packages/bruno-electron/src/ipc/network/oauth2-helper.js
+++ /dev/null
@@ -1,122 +0,0 @@
-const { get, cloneDeep } = require('lodash');
-const crypto = require('crypto');
-const { authorizeUserInWindow } = require('./authorize-user-in-window');
-const Oauth2Store = require('../../store/oauth2');
-
-const generateCodeVerifier = () => {
- return crypto.randomBytes(22).toString('hex');
-};
-
-const generateCodeChallenge = (codeVerifier) => {
- const hash = crypto.createHash('sha256');
- hash.update(codeVerifier);
- const base64Hash = hash.digest('base64');
- return base64Hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
-};
-
-// AUTHORIZATION CODE
-
-const resolveOAuth2AuthorizationCodeAccessToken = async (request, collectionUid) => {
- let codeVerifier = generateCodeVerifier();
- let codeChallenge = generateCodeChallenge(codeVerifier);
-
- let requestCopy = cloneDeep(request);
- const { authorizationCode } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid);
- const oAuth = get(requestCopy, 'oauth2', {});
- const { clientId, clientSecret, callbackUrl, scope, pkce } = oAuth;
- const data = {
- grant_type: 'authorization_code',
- code: authorizationCode,
- redirect_uri: callbackUrl,
- client_id: clientId,
- client_secret: clientSecret,
- scope: scope
- };
- if (pkce) {
- data['code_verifier'] = codeVerifier;
- }
-
- const url = requestCopy?.oauth2?.accessTokenUrl;
- return {
- data,
- url
- };
-};
-
-const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
- return new Promise(async (resolve, reject) => {
- const { oauth2 } = request;
- const { callbackUrl, clientId, authorizationUrl, scope, pkce } = oauth2;
-
- let oauth2QueryParams =
- (authorizationUrl.indexOf('?') > -1 ? '&' : '?') + `client_id=${clientId}&response_type=code`;
- if (callbackUrl) {
- oauth2QueryParams += `&redirect_uri=${callbackUrl}`;
- }
- if (scope) {
- oauth2QueryParams += `&scope=${scope}`;
- }
- if (pkce) {
- oauth2QueryParams += `&code_challenge=${codeChallenge}&code_challenge_method=S256`;
- }
- const authorizationUrlWithQueryParams = authorizationUrl + oauth2QueryParams;
- try {
- const oauth2Store = new Oauth2Store();
- const { authorizationCode } = await authorizeUserInWindow({
- authorizeUrl: authorizationUrlWithQueryParams,
- callbackUrl,
- session: oauth2Store.getSessionIdOfCollection(collectionUid)
- });
- resolve({ authorizationCode });
- } catch (err) {
- reject(err);
- }
- });
-};
-
-// CLIENT CREDENTIALS
-
-const transformClientCredentialsRequest = async (request) => {
- let requestCopy = cloneDeep(request);
- const oAuth = get(requestCopy, 'oauth2', {});
- const { clientId, clientSecret, scope } = oAuth;
- const data = {
- grant_type: 'client_credentials',
- client_id: clientId,
- client_secret: clientSecret,
- scope
- };
- const url = requestCopy?.oauth2?.accessTokenUrl;
- return {
- data,
- url
- };
-};
-
-// PASSWORD CREDENTIALS
-
-const transformPasswordCredentialsRequest = async (request) => {
- let requestCopy = cloneDeep(request);
- const oAuth = get(requestCopy, 'oauth2', {});
- const { username, password, clientId, clientSecret, scope } = oAuth;
- const data = {
- grant_type: 'password',
- username,
- password,
- client_id: clientId,
- client_secret: clientSecret,
- scope
- };
- const url = requestCopy?.oauth2?.accessTokenUrl;
- return {
- data,
- url
- };
-};
-
-module.exports = {
- resolveOAuth2AuthorizationCodeAccessToken,
- getOAuth2AuthorizationCode,
- transformClientCredentialsRequest,
- transformPasswordCredentialsRequest
-};
diff --git a/packages/bruno-electron/src/ipc/network/prepare-collection-request.js b/packages/bruno-electron/src/ipc/network/prepare-collection-request.js
deleted file mode 100644
index 5fd630594..000000000
--- a/packages/bruno-electron/src/ipc/network/prepare-collection-request.js
+++ /dev/null
@@ -1,49 +0,0 @@
-const { get, each } = require('lodash');
-const { setAuthHeaders } = require('./prepare-request');
-
-const prepareCollectionRequest = (request, collectionRoot) => {
- const headers = {};
- let contentTypeDefined = false;
- let url = request.url;
-
- // collection headers
- each(get(collectionRoot, 'request.headers', []), (h) => {
- if (h.enabled) {
- headers[h.name] = h.value;
- if (h.name.toLowerCase() === 'content-type') {
- contentTypeDefined = true;
- }
- }
- });
-
- each(request.headers, (h) => {
- if (h.enabled) {
- headers[h.name] = h.value;
- if (h.name.toLowerCase() === 'content-type') {
- contentTypeDefined = true;
- }
- }
- });
-
- let axiosRequest = {
- mode: request?.body?.mode,
- method: request.method,
- url,
- headers,
- responseType: 'arraybuffer'
- };
-
- axiosRequest = setAuthHeaders(axiosRequest, request, collectionRoot);
-
- if (request.script) {
- axiosRequest.script = request.script;
- }
-
- axiosRequest.vars = request.vars;
-
- axiosRequest.method = 'POST';
-
- return axiosRequest;
-};
-
-module.exports = prepareCollectionRequest;
diff --git a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js
index c137c4b33..158a71dc6 100644
--- a/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js
+++ b/packages/bruno-electron/src/ipc/network/prepare-gql-introspection-request.js
@@ -3,9 +3,9 @@ const { interpolate } = require('@usebruno/common');
const { getIntrospectionQuery } = require('graphql');
const { setAuthHeaders } = require('./prepare-request');
-const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRoot) => {
+const prepareGqlIntrospectionRequest = (endpoint, resolvedVars, request, collectionRoot) => {
if (endpoint && endpoint.length) {
- endpoint = interpolate(endpoint, envVars);
+ endpoint = interpolate(endpoint, resolvedVars);
}
const queryParams = {
@@ -16,7 +16,7 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo
method: 'POST',
url: endpoint,
headers: {
- ...mapHeaders(request.headers, get(collectionRoot, 'request.headers', [])),
+ ...mapHeaders(request.headers, get(collectionRoot, 'request.headers', []), resolvedVars),
Accept: 'application/json',
'Content-Type': 'application/json'
},
@@ -26,19 +26,20 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo
return setAuthHeaders(axiosRequest, request, collectionRoot);
};
-const mapHeaders = (requestHeaders, collectionHeaders) => {
+const mapHeaders = (requestHeaders, collectionHeaders, resolvedVars) => {
const headers = {};
- each(requestHeaders, (h) => {
+ // Add collection headers first
+ each(collectionHeaders, (h) => {
if (h.enabled) {
- headers[h.name] = h.value;
+ headers[h.name] = interpolate(h.value, resolvedVars);
}
});
- // collection headers
- each(collectionHeaders, (h) => {
+ // Then add request headers, which will overwrite if names overlap
+ each(requestHeaders, (h) => {
if (h.enabled) {
- headers[h.name] = h.value;
+ headers[h.name] = interpolate(h.value, resolvedVars);
}
});
diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js
index e8c88275f..9efa56e45 100644
--- a/packages/bruno-electron/src/ipc/network/prepare-request.js
+++ b/packages/bruno-electron/src/ipc/network/prepare-request.js
@@ -1,34 +1,10 @@
-const { get, each, filter, extend } = require('lodash');
+const { get, each, filter, find } = require('lodash');
const decomment = require('decomment');
-var JSONbig = require('json-bigint');
-const FormData = require('form-data');
-const fs = require('fs');
-const path = require('path');
-
-const parseFormData = (datas, collectionPath) => {
- // make axios work in node using form data
- // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
- const form = new FormData();
- datas.forEach((item) => {
- const value = item.value;
- const name = item.name;
- if (item.type === 'file') {
- const filePaths = value || [];
- filePaths.forEach((filePath) => {
- let trimmedFilePath = filePath.trim();
-
- if (!path.isAbsolute(trimmedFilePath)) {
- trimmedFilePath = path.join(collectionPath, trimmedFilePath);
- }
-
- form.append(name, fs.createReadStream(trimmedFilePath), path.basename(trimmedFilePath));
- });
- } else {
- form.append(name, value);
- }
- });
- return form;
-};
+const crypto = require('node:crypto');
+const fs = require('node:fs/promises');
+const { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeVars, getFormattedCollectionOauth2Credentials, mergeAuth } = require('../../utils/collection');
+const { buildFormUrlEncodedPayload } = require('../../utils/form-data');
+const path = require('node:path');
const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
const collectionAuth = get(collectionRoot, 'request.auth');
@@ -45,7 +21,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'basic':
- axiosRequest.auth = {
+ axiosRequest.basicAuth = {
username: get(collectionAuth, 'basic.username'),
password: get(collectionAuth, 'basic.password')
};
@@ -59,6 +35,101 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(collectionAuth, 'digest.password')
};
break;
+ case 'ntlm':
+ axiosRequest.ntlmConfig = {
+ username: get(collectionAuth, 'ntlm.username'),
+ password: get(collectionAuth, 'ntlm.password'),
+ domain: get(collectionAuth, 'ntlm.domain')
+ };
+ break;
+ case 'wsse':
+ const username = get(request, 'auth.wsse.username', '');
+ const password = get(request, 'auth.wsse.password', '');
+
+ const ts = new Date().toISOString();
+ const nonce = crypto.randomBytes(16).toString('hex');
+
+ // Create the password digest using SHA-1 as required for WSSE
+ const hash = crypto.createHash('sha1');
+ hash.update(nonce + ts + password);
+ const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
+
+ // Construct the WSSE header
+ axiosRequest.headers[
+ 'X-WSSE'
+ ] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
+ break;
+ case 'apikey':
+ const apiKeyAuth = get(collectionAuth, 'apikey');
+ if (apiKeyAuth.placement === 'header') {
+ axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
+ } else if (apiKeyAuth.placement === 'queryparams') {
+ // If the API key authentication is set and its placement is 'queryparams', add it to the axios request object. This will be used in the configureRequest function to append the API key to the query parameters of the request URL.
+ axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
+ }
+ break;
+ case 'oauth2':
+ const grantType = get(collectionAuth, 'oauth2.grantType');
+ switch (grantType) {
+ case 'password':
+ axiosRequest.oauth2 = {
+ grantType: grantType,
+ accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
+ username: get(collectionAuth, 'oauth2.username'),
+ password: get(collectionAuth, 'oauth2.password'),
+ clientId: get(collectionAuth, 'oauth2.clientId'),
+ clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
+ scope: get(collectionAuth, 'oauth2.scope'),
+ credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
+ credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
+ tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
+ autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
+ autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
+ };
+ break;
+ case 'authorization_code':
+ axiosRequest.oauth2 = {
+ grantType: grantType,
+ callbackUrl: get(collectionAuth, 'oauth2.callbackUrl'),
+ authorizationUrl: get(collectionAuth, 'oauth2.authorizationUrl'),
+ accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
+ clientId: get(collectionAuth, 'oauth2.clientId'),
+ clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
+ scope: get(collectionAuth, 'oauth2.scope'),
+ state: get(collectionAuth, 'oauth2.state'),
+ pkce: get(collectionAuth, 'oauth2.pkce'),
+ credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
+ credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
+ tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
+ autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
+ autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
+ };
+ break;
+ case 'client_credentials':
+ axiosRequest.oauth2 = {
+ grantType: grantType,
+ accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
+ clientId: get(collectionAuth, 'oauth2.clientId'),
+ clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
+ scope: get(collectionAuth, 'oauth2.scope'),
+ credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
+ credentialsId: get(collectionAuth, 'oauth2.credentialsId'),
+ tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey'),
+ autoFetchToken: get(collectionAuth, 'oauth2.autoFetchToken'),
+ autoRefreshToken: get(collectionAuth, 'oauth2.autoRefreshToken')
+ };
+ break;
+ }
+ break;
}
}
@@ -75,7 +146,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'basic':
- axiosRequest.auth = {
+ axiosRequest.basicAuth = {
username: get(request, 'auth.basic.username'),
password: get(request, 'auth.basic.password')
};
@@ -89,6 +160,12 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
password: get(request, 'auth.digest.password')
};
break;
+ case 'ntlm':
+ axiosRequest.ntlmConfig = {
+ username: get(request, 'auth.ntlm.username'),
+ password: get(request, 'auth.ntlm.password'),
+ domain: get(request, 'auth.ntlm.domain')
+ };
case 'oauth2':
const grantType = get(request, 'auth.oauth2.grantType');
switch (grantType) {
@@ -96,11 +173,19 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
- scope: get(request, 'auth.oauth2.scope')
+ scope: get(request, 'auth.oauth2.scope'),
+ credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
+ credentialsId: get(request, 'auth.oauth2.credentialsId'),
+ tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
+ autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
+ autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
case 'authorization_code':
@@ -109,45 +194,100 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
callbackUrl: get(request, 'auth.oauth2.callbackUrl'),
authorizationUrl: get(request, 'auth.oauth2.authorizationUrl'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
- pkce: get(request, 'auth.oauth2.pkce')
+ state: get(request, 'auth.oauth2.state'),
+ pkce: get(request, 'auth.oauth2.pkce'),
+ credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
+ credentialsId: get(request, 'auth.oauth2.credentialsId'),
+ tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
+ autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
+ autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
case 'client_credentials':
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
+ refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
- scope: get(request, 'auth.oauth2.scope')
+ scope: get(request, 'auth.oauth2.scope'),
+ credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
+ credentialsId: get(request, 'auth.oauth2.credentialsId'),
+ tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
+ tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
+ tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey'),
+ autoFetchToken: get(request, 'auth.oauth2.autoFetchToken'),
+ autoRefreshToken: get(request, 'auth.oauth2.autoRefreshToken')
};
break;
}
break;
+ case 'wsse':
+ const username = get(request, 'auth.wsse.username', '');
+ const password = get(request, 'auth.wsse.password', '');
+
+ const ts = new Date().toISOString();
+ const nonce = crypto.randomBytes(16).toString('hex');
+
+ // Create the password digest using SHA-1 as required for WSSE
+ const hash = crypto.createHash('sha1');
+ hash.update(nonce + ts + password);
+ const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
+
+ // Construct the WSSE header
+ axiosRequest.headers[
+ 'X-WSSE'
+ ] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
+ break;
+ case 'apikey':
+ const apiKeyAuth = get(request, 'auth.apikey');
+ if (apiKeyAuth.placement === 'header') {
+ axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
+ } else if (apiKeyAuth.placement === 'queryparams') {
+ // If the API key authentication is set and its placement is 'queryparams', add it to the axios request object. This will be used in the configureRequest function to append the API key to the query parameters of the request URL.
+ axiosRequest.apiKeyAuthValueForQueryParams = apiKeyAuth;
+ }
+ break;
}
}
return axiosRequest;
};
-const prepareRequest = (request, collectionRoot, collectionPath) => {
+const prepareRequest = async (item, collection = {}, abortController) => {
+ const request = item.draft ? item.draft.request : item.request;
+ const collectionRoot = collection?.draft ? get(collection, 'draft', {}) : get(collection, 'root', {});
+ const collectionPath = collection?.pathname;
const headers = {};
let contentTypeDefined = false;
let url = request.url;
-
- // collection headers
+
each(get(collectionRoot, 'request.headers', []), (h) => {
- if (h.enabled && h.name.length > 0) {
- headers[h.name] = h.value;
- if (h.name.toLowerCase() === 'content-type') {
- contentTypeDefined = true;
- }
+ if (h.enabled && h.name?.toLowerCase() === 'content-type') {
+ contentTypeDefined = true;
+ return false;
}
});
+
+ const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich';
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ if (requestTreePath && requestTreePath.length > 0) {
+ mergeHeaders(collection, request, requestTreePath);
+ mergeScripts(collection, request, requestTreePath, scriptFlow);
+ mergeVars(collection, request, requestTreePath);
+ mergeAuth(collection, request, requestTreePath);
+ request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
+ request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
+ }
- each(request.headers, (h) => {
+
+ each(get(request, 'headers', []), (h) => {
if (h.enabled && h.name.length > 0) {
headers[h.name] = h.value;
if (h.name.toLowerCase() === 'content-type') {
@@ -161,6 +301,8 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
method: request.method,
url,
headers,
+ name: item.name,
+ pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};
@@ -171,9 +313,9 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
- axiosRequest.data = JSONbig.parse(decomment(request.body.json));
- } catch (ex) {
- axiosRequest.data = request.body.json;
+ axiosRequest.data = decomment(request?.body?.json);
+ } catch (error) {
+ axiosRequest.data = request?.body?.json;
}
}
@@ -186,7 +328,7 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
if (request.body.mode === 'xml') {
if (!contentTypeDefined) {
- axiosRequest.headers['content-type'] = 'text/xml';
+ axiosRequest.headers['content-type'] = 'application/xml';
}
axiosRequest.data = request.body.xml;
}
@@ -198,19 +340,45 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
axiosRequest.data = request.body.sparql;
}
+ if (request.body.mode === 'file') {
+ if (!contentTypeDefined) {
+ axiosRequest.headers['content-type'] = 'application/octet-stream'; // Default headers for binary file uploads
+ }
+
+ const bodyFile = find(request.body.file, (param) => param.selected);
+ if (bodyFile) {
+ let { filePath, contentType } = bodyFile;
+
+ axiosRequest.headers['content-type'] = contentType;
+ if (filePath) {
+ if (!path.isAbsolute(filePath)) {
+ filePath = path.join(collectionPath, filePath);
+ }
+
+ try {
+ const fileContent = await fs.readFile(filePath);
+ axiosRequest.data = fileContent;
+ } catch (error) {
+ console.error('Error reading file:', error);
+ }
+ }
+ }
+ }
+
if (request.body.mode === 'formUrlEncoded') {
- axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';
- const params = {};
+ if (!contentTypeDefined) {
+ axiosRequest.headers['content-type'] = 'application/x-www-form-urlencoded';
+ }
const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled);
- each(enabledParams, (p) => (params[p.name] = p.value));
- axiosRequest.data = params;
+ axiosRequest.data = buildFormUrlEncodedPayload(enabledParams);
}
if (request.body.mode === 'multipartForm') {
+ if (!contentTypeDefined) {
+ axiosRequest.headers['content-type'] = 'multipart/form-data';
+ }
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
- const form = parseFormData(enabledParams, collectionPath);
- extend(axiosRequest.headers, form.getHeaders());
- axiosRequest.data = form;
+ axiosRequest.data = enabledParams;
}
if (request.body.mode === 'graphql') {
@@ -229,11 +397,23 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
axiosRequest.script = request.script;
}
+ if (request.tests) {
+ axiosRequest.tests = request.tests;
+ }
+
axiosRequest.vars = request.vars;
+ axiosRequest.collectionVariables = request.collectionVariables;
+ axiosRequest.folderVariables = request.folderVariables;
+ axiosRequest.requestVariables = request.requestVariables;
+ axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables;
+ axiosRequest.oauth2CredentialVariables = request.oauth2CredentialVariables;
axiosRequest.assertions = request.assertions;
+ axiosRequest.oauth2Credentials = request.oauth2Credentials;
return axiosRequest;
};
-module.exports = prepareRequest;
-module.exports.setAuthHeaders = setAuthHeaders;
+module.exports = {
+ prepareRequest,
+ setAuthHeaders
+}
\ No newline at end of file
diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js
index f07c79c06..4c9c34d99 100644
--- a/packages/bruno-electron/src/ipc/preferences.js
+++ b/packages/bruno-electron/src/ipc/preferences.js
@@ -1,7 +1,8 @@
const { ipcMain } = require('electron');
-const { getPreferences, savePreferences } = require('../store/preferences');
+const { getPreferences, savePreferences, preferencesUtil } = require('../store/preferences');
const { isDirectory } = require('../utils/filesystem');
const { openCollection } = require('../app/collections');
+const { globalEnvironmentsStore } = require('../store/global-environments');
``;
const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
ipcMain.handle('renderer:ready', async (event) => {
@@ -9,6 +10,17 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
const preferences = getPreferences();
mainWindow.webContents.send('main:load-preferences', preferences);
+ // load system proxy vars
+ const systemProxyVars = preferencesUtil.getSystemProxyEnvVariables();
+ const { http_proxy, https_proxy, no_proxy } = systemProxyVars || {};
+ mainWindow.webContents.send('main:load-system-proxy-env', { http_proxy, https_proxy, no_proxy });
+
+ // load global environments
+ const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
+ let activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
+ activeGlobalEnvironmentUid = globalEnvironments?.find(env => env?.uid == activeGlobalEnvironmentUid) ? activeGlobalEnvironmentUid : null;
+ mainWindow.webContents.send('main:load-global-environments', { globalEnvironments, activeGlobalEnvironmentUid });
+
// reload last opened collections
const lastOpened = lastOpenedCollections.getAll();
diff --git a/packages/bruno-electron/src/preload.js b/packages/bruno-electron/src/preload.js
index 4bfe2216b..83dfcada8 100644
--- a/packages/bruno-electron/src/preload.js
+++ b/packages/bruno-electron/src/preload.js
@@ -1,4 +1,4 @@
-const { ipcRenderer, contextBridge } = require('electron');
+const { ipcRenderer, contextBridge, webUtils } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
@@ -10,5 +10,9 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
return () => {
ipcRenderer.removeListener(channel, subscription);
};
+ },
+ getFilePath (file) {
+ const path = webUtils.getPathForFile(file)
+ return path;
}
});
diff --git a/packages/bruno-electron/src/store/bruno-config.js b/packages/bruno-electron/src/store/bruno-config.js
index a092e9357..8942dd833 100644
--- a/packages/bruno-electron/src/store/bruno-config.js
+++ b/packages/bruno-electron/src/store/bruno-config.js
@@ -4,7 +4,7 @@
const config = {};
-// collectionUid is a hash based on the collection path)
+// collectionUid is a hash based on the collection path
const getBrunoConfig = (collectionUid) => {
return config[collectionUid] || {};
};
diff --git a/packages/bruno-electron/src/store/collection-security.js b/packages/bruno-electron/src/store/collection-security.js
new file mode 100644
index 000000000..5873c629a
--- /dev/null
+++ b/packages/bruno-electron/src/store/collection-security.js
@@ -0,0 +1,39 @@
+const _ = require('lodash');
+const Store = require('electron-store');
+
+class CollectionSecurityStore {
+ constructor() {
+ this.store = new Store({
+ name: 'collection-security',
+ clearInvalidConfig: true
+ });
+ }
+
+ setSecurityConfigForCollection(collectionPathname, securityConfig) {
+ const collections = this.store.get('collections') || [];
+ const collection = _.find(collections, (c) => c.path === collectionPathname);
+
+ if (!collection) {
+ collections.push({
+ path: collectionPathname,
+ securityConfig: {
+ jsSandboxMode: securityConfig.jsSandboxMode
+ }
+ });
+
+ this.store.set('collections', collections);
+ return;
+ }
+
+ collection.securityConfig = securityConfig || {};
+ this.store.set('collections', collections);
+ }
+
+ getSecurityConfigForCollection(collectionPathname) {
+ const collections = this.store.get('collections') || [];
+ const collection = _.find(collections, (c) => c.path === collectionPathname);
+ return collection?.securityConfig || {};
+ }
+}
+
+module.exports = CollectionSecurityStore;
diff --git a/packages/bruno-electron/src/store/env-secrets.js b/packages/bruno-electron/src/store/env-secrets.js
index 8ded05ae9..894f7bc7a 100644
--- a/packages/bruno-electron/src/store/env-secrets.js
+++ b/packages/bruno-electron/src/store/env-secrets.js
@@ -27,17 +27,13 @@ class EnvironmentSecretsStore {
});
}
- isValidValue(val) {
- return typeof val === 'string' && val.length >= 0;
- }
-
storeEnvSecrets(collectionPathname, environment) {
const envVars = [];
_.each(environment.variables, (v) => {
if (v.secret) {
envVars.push({
name: v.name,
- value: this.isValidValue(v.value) ? encryptString(v.value) : ''
+ value: encryptString(v.value)
});
}
});
diff --git a/packages/bruno-electron/src/store/global-environments.js b/packages/bruno-electron/src/store/global-environments.js
new file mode 100644
index 000000000..f9e9f175f
--- /dev/null
+++ b/packages/bruno-electron/src/store/global-environments.js
@@ -0,0 +1,132 @@
+const _ = require('lodash');
+const Store = require('electron-store');
+const { encryptString, decryptString } = require('../utils/encryption');
+
+class GlobalEnvironmentsStore {
+ constructor() {
+ this.store = new Store({
+ name: 'global-environments',
+ clearInvalidConfig: true
+ });
+ }
+
+ encryptGlobalEnvironmentVariables({ globalEnvironments }) {
+ return globalEnvironments?.map(env => {
+ const variables = env.variables?.map(v => ({
+ ...v,
+ value: v?.secret ? encryptString(v.value) : v?.value
+ })) || [];
+
+ return {
+ ...env,
+ variables
+ };
+ });
+ }
+
+ decryptGlobalEnvironmentVariables({ globalEnvironments }) {
+ return globalEnvironments?.map(env => {
+ const variables = env.variables?.map(v => ({
+ ...v,
+ value: v?.secret ? decryptString(v.value) : v?.value
+ })) || [];
+
+ return {
+ ...env,
+ variables
+ };
+ });
+ }
+
+ getGlobalEnvironments() {
+ let globalEnvironments = this.store.get('environments', []);
+ globalEnvironments = this.decryptGlobalEnvironmentVariables({ globalEnvironments });
+ return globalEnvironments;
+ }
+
+ getActiveGlobalEnvironmentUid() {
+ return this.store.get('activeGlobalEnvironmentUid', null);
+ }
+
+ setGlobalEnvironments(globalEnvironments) {
+ globalEnvironments = this.encryptGlobalEnvironmentVariables({ globalEnvironments });
+ return this.store.set('environments', globalEnvironments);
+ }
+
+ setActiveGlobalEnvironmentUid(uid) {
+ return this.store.set('activeGlobalEnvironmentUid', uid);
+ }
+
+ addGlobalEnvironment({ uid, name, variables = [] }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ const existingEnvironment = globalEnvironments.find(env => env?.name == name);
+ if (existingEnvironment) {
+ throw new Error('Environment with the same name already exists');
+ }
+ globalEnvironments.push({
+ uid,
+ name,
+ variables
+ });
+ this.setGlobalEnvironments(globalEnvironments);
+ }
+
+ saveGlobalEnvironment({ environmentUid: globalEnvironmentUid, variables }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ globalEnvironments = globalEnvironments.filter(env => env?.uid !== globalEnvironmentUid);
+ if (environment) {
+ environment.variables = variables;
+ }
+ globalEnvironments.push(environment);
+ this.setGlobalEnvironments(globalEnvironments);
+
+ }
+
+ renameGlobalEnvironment({ environmentUid: globalEnvironmentUid, name }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ globalEnvironments = globalEnvironments.filter(env => env?.uid !== globalEnvironmentUid);
+ if (environment) {
+ environment.name = name;
+ }
+ globalEnvironments.push(environment);
+ this.setGlobalEnvironments(globalEnvironments);
+ }
+
+ copyGlobalEnvironment({ uid, name, variables }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ globalEnvironments.push({
+ uid,
+ name,
+ variables
+ });
+ this.setGlobalEnvironments(globalEnvironments);
+ }
+
+ selectGlobalEnvironment({ environmentUid: globalEnvironmentUid }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ const environment = globalEnvironments.find(env => env?.uid == globalEnvironmentUid);
+ if (environment) {
+ this.setActiveGlobalEnvironmentUid(globalEnvironmentUid);
+ } else {
+ this.setActiveGlobalEnvironmentUid(null);
+ }
+ }
+
+ deleteGlobalEnvironment({ environmentUid }) {
+ let globalEnvironments = this.getGlobalEnvironments();
+ let activeGlobalEnvironmentUid = this.getActiveGlobalEnvironmentUid();
+ globalEnvironments = globalEnvironments.filter(env => env?.uid !== environmentUid);
+ if (environmentUid == activeGlobalEnvironmentUid) {
+ this.setActiveGlobalEnvironmentUid(null);
+ }
+ this.setGlobalEnvironments(globalEnvironments);
+ }
+}
+
+const globalEnvironmentsStore = new GlobalEnvironmentsStore();
+
+module.exports = {
+ globalEnvironmentsStore
+};
diff --git a/packages/bruno-electron/src/store/last-opened-collections.js b/packages/bruno-electron/src/store/last-opened-collections.js
index 546b73b57..72452eef3 100644
--- a/packages/bruno-electron/src/store/last-opened-collections.js
+++ b/packages/bruno-electron/src/store/last-opened-collections.js
@@ -16,18 +16,20 @@ class LastOpenedCollections {
}
add(collectionPath) {
- const collections = this.store.get('lastOpenedCollections') || [];
+ const collections = this.getAll();
- if (isDirectory(collectionPath)) {
- if (!collections.includes(collectionPath)) {
- collections.push(collectionPath);
- this.store.set('lastOpenedCollections', collections);
- }
+ if (isDirectory(collectionPath) && !collections.includes(collectionPath)) {
+ collections.push(collectionPath);
+ this.store.set('lastOpenedCollections', collections);
}
}
+ update(collectionPaths) {
+ this.store.set('lastOpenedCollections', collectionPaths);
+ }
+
remove(collectionPath) {
- let collections = this.store.get('lastOpenedCollections') || [];
+ let collections = this.getAll();
if (collections.includes(collectionPath)) {
collections = _.filter(collections, (c) => c !== collectionPath);
@@ -36,7 +38,7 @@ class LastOpenedCollections {
}
removeAll() {
- return this.store.set('lastOpenedCollections', []);
+ this.store.set('lastOpenedCollections', []);
}
}
diff --git a/packages/bruno-electron/src/store/oauth2.js b/packages/bruno-electron/src/store/oauth2.js
index b0a2255b5..8c009db7b 100644
--- a/packages/bruno-electron/src/store/oauth2.js
+++ b/packages/bruno-electron/src/store/oauth2.js
@@ -1,24 +1,42 @@
const _ = require('lodash');
const Store = require('electron-store');
-const { uuid } = require('../utils/common');
+const { uuid, safeStringifyJSON, safeParseJSON } = require('../utils/common');
+const { encryptString, decryptString } = require('../utils/encryption');
+
+/**
+ * Sample secrets store file
+ *
+ * {
+ * "collections": [{
+ * "path": "/Users/anoop/Code/acme-acpi-collection",
+ * "environments" : [{
+ * "name": "Local",
+ * "secrets": [{
+ * "name": "token",
+ * "value": "abracadabra"
+ * }]
+ * }]
+ * }]
+ * }
+ */
class Oauth2Store {
constructor() {
this.store = new Store({
- name: 'preferences',
+ name: 'oauth2',
clearInvalidConfig: true
});
}
// Get oauth2 data for all collections
getAllOauth2Data() {
- let oauth2Data = this.store.get('oauth2');
+ let oauth2Data = this.store.get('collections');
if (!Array.isArray(oauth2Data)) oauth2Data = [];
return oauth2Data;
}
// Get oauth2 data for a collection
- getOauth2DataOfCollection(collectionUid) {
+ getOauth2DataOfCollection({ collectionUid, url }) {
let oauth2Data = this.getAllOauth2Data();
let oauth2DataForCollection = oauth2Data.find((d) => d?.collectionUid == collectionUid);
@@ -28,7 +46,7 @@ class Oauth2Store {
collectionUid
};
let updatedOauth2Data = [...oauth2Data, newOauth2DataForCollection];
- this.store.set('oauth2', updatedOauth2Data);
+ this.store.set('collections', updatedOauth2Data);
return newOauth2DataForCollection;
}
@@ -37,18 +55,18 @@ class Oauth2Store {
}
// Update oauth2 data of a collection
- updateOauth2DataOfCollection(collectionUid, data) {
+ updateOauth2DataOfCollection({ collectionUid, url, data }) {
let oauth2Data = this.getAllOauth2Data();
let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid);
updatedOauth2Data.push({ ...data });
- this.store.set('oauth2', updatedOauth2Data);
+ this.store.set('collections', updatedOauth2Data);
}
// Create a new oauth2 Session Id for a collection
- createNewOauth2SessionIdForCollection(collectionUid) {
- let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
+ createNewOauth2SessionIdForCollection({ collectionUid, url }) {
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let newSessionId = uuid();
@@ -57,21 +75,21 @@ class Oauth2Store {
sessionId: newSessionId
};
- this.updateOauth2DataOfCollection(collectionUid, newOauth2DataForCollection);
+ this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
return newOauth2DataForCollection;
}
// Get session id of a collection
- getSessionIdOfCollection(collectionUid) {
+ getSessionIdOfCollection({ collectionUid, url }) {
try {
- let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
if (oauth2DataForCollection?.sessionId && typeof oauth2DataForCollection.sessionId === 'string') {
return oauth2DataForCollection.sessionId;
}
- let newOauth2DataForCollection = this.createNewOauth2SessionIdForCollection(collectionUid);
+ let newOauth2DataForCollection = this.createNewOauth2SessionIdForCollection({ collectionUid, url });
return newOauth2DataForCollection?.sessionId;
} catch (err) {
console.log('error retrieving session id from cache', err);
@@ -79,21 +97,71 @@ class Oauth2Store {
}
// clear session id of a collection
- clearSessionIdOfCollection(collectionUid) {
+ clearSessionIdOfCollection({ collectionUid, url }) {
try {
let oauth2Data = this.getAllOauth2Data();
- let oauth2DataForCollection = this.getOauth2DataOfCollection(collectionUid);
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
delete oauth2DataForCollection.sessionId;
+ delete oauth2DataForCollection.credentials;
let updatedOauth2Data = oauth2Data.filter((d) => d.collectionUid !== collectionUid);
updatedOauth2Data.push({ ...oauth2DataForCollection });
- this.store.set('oauth2', updatedOauth2Data);
+ this.store.set('collections', updatedOauth2Data);
} catch (err) {
console.log('error while clearing the oauth2 session cache', err);
}
}
+
+ getCredentialsForCollection({ collectionUid, url, credentialsId }) {
+ try {
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
+ let credentials = oauth2DataForCollection?.credentials?.find(c => (c?.url == url) && (c?.credentialsId == credentialsId));
+ if (!credentials?.data) return null;
+ let decryptedCredentialsData = safeParseJSON(decryptString(credentials?.data));
+ return decryptedCredentialsData;
+ } catch (err) {
+ console.log('error retrieving oauth2 credentials from cache', err);
+ }
+ }
+
+ updateCredentialsForCollection({ collectionUid, url, credentialsId, credentials = {} }) {
+ try {
+ let encryptedCredentialsData = encryptString(safeStringifyJSON(credentials));
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
+ let filteredCredentials = oauth2DataForCollection?.credentials?.filter(c => (c?.url !== url) || (c?.credentialsId !== credentialsId));
+ if (!filteredCredentials) filteredCredentials = [];
+ filteredCredentials.push({
+ url,
+ data: encryptedCredentialsData,
+ credentialsId
+ });
+ let newOauth2DataForCollection = {
+ ...oauth2DataForCollection,
+ credentials: filteredCredentials
+ };
+ this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
+ return newOauth2DataForCollection;
+ } catch (err) {
+ console.log('error updating oauth2 credentials from cache', err);
+ }
+ }
+
+ clearCredentialsForCollection({ collectionUid, url, credentialsId }) {
+ try {
+ let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
+ let filteredCredentials = oauth2DataForCollection?.credentials?.filter(c => (c?.url !== url) || (c?.credentialsId !== credentialsId));
+ let newOauth2DataForCollection = {
+ ...oauth2DataForCollection,
+ credentials: filteredCredentials
+ };
+ this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
+ return newOauth2DataForCollection;
+ } catch (err) {
+ console.log('error clearing oauth2 credentials from cache', err);
+ }
+ }
}
module.exports = Oauth2Store;
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index 844e96b06..33d7a02f8 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -1,6 +1,6 @@
const Yup = require('yup');
const Store = require('electron-store');
-const { get } = require('lodash');
+const { get, merge } = require('lodash');
/**
* The preferences are stored in the electron store 'preferences.json'.
@@ -16,17 +16,18 @@ const defaultPreferences = {
filePath: null
},
keepDefaultCaCertificates: {
- enabled: false
+ enabled: true
},
storeCookies: true,
sendCookies: true,
timeout: 0
},
font: {
- codeFont: 'default'
+ codeFont: 'default',
+ codeFontSize: 14
},
proxy: {
- enabled: false,
+ mode: 'off',
protocol: 'http',
hostname: '',
port: null,
@@ -54,10 +55,11 @@ const preferencesSchema = Yup.object().shape({
timeout: Yup.number()
}),
font: Yup.object().shape({
- codeFont: Yup.string().nullable()
+ codeFont: Yup.string().nullable(),
+ codeFontSize: Yup.number().min(1).max(32).nullable()
}),
proxy: Yup.object({
- enabled: Yup.boolean(),
+ mode: Yup.string().oneOf(['off', 'on', 'system']),
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
port: Yup.number().min(1).max(65535).nullable(),
@@ -79,10 +81,22 @@ class PreferencesStore {
}
getPreferences() {
- return {
- ...defaultPreferences,
- ...this.store.get('preferences')
- };
+ let preferences = this.store.get('preferences', {});
+
+ // This to support the old preferences format
+ // In the old format, we had a proxy.enabled flag
+ // In the new format, this maps to proxy.mode = 'on'
+ if (preferences?.proxy?.enabled) {
+ preferences.proxy.mode = 'on';
+ }
+
+ // Delete the proxy.enabled property if it exists, regardless of its value
+ // This is a part of migration to the new preferences format
+ if (preferences?.proxy && 'enabled' in preferences.proxy) {
+ delete preferences.proxy.enabled;
+ }
+
+ return merge({}, defaultPreferences, preferences);
}
savePreferences(newPreferences) {
@@ -118,7 +132,7 @@ const preferencesUtil = {
return get(getPreferences(), 'request.customCaCertificate.enabled', false);
},
shouldKeepDefaultCaCertificates: () => {
- return get(getPreferences(), 'request.keepDefaultCaCertificates.enabled', false);
+ return get(getPreferences(), 'request.keepDefaultCaCertificates.enabled', true);
},
getCustomCaCertificateFilePath: () => {
return get(getPreferences(), 'request.customCaCertificate.filePath', null);
@@ -134,6 +148,14 @@ const preferencesUtil = {
},
shouldSendCookies: () => {
return get(getPreferences(), 'request.sendCookies', true);
+ },
+ getSystemProxyEnvVariables: () => {
+ const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env;
+ return {
+ http_proxy: http_proxy || HTTP_PROXY,
+ https_proxy: https_proxy || HTTPS_PROXY,
+ no_proxy: no_proxy || NO_PROXY
+ };
}
};
diff --git a/packages/bruno-electron/src/store/process-env.js b/packages/bruno-electron/src/store/process-env.js
index 578d8df71..084187d2d 100644
--- a/packages/bruno-electron/src/store/process-env.js
+++ b/packages/bruno-electron/src/store/process-env.js
@@ -11,7 +11,7 @@
const dotEnvVars = {};
-// collectionUid is a hash based on the collection path)
+// collectionUid is a hash based on the collection path
const getProcessEnvVars = (collectionUid) => {
// if there are no .env vars for this collection, return the process.env
if (!dotEnvVars[collectionUid]) {
diff --git a/packages/bruno-electron/src/store/ui-state-snapshot.js b/packages/bruno-electron/src/store/ui-state-snapshot.js
new file mode 100644
index 000000000..a130c36de
--- /dev/null
+++ b/packages/bruno-electron/src/store/ui-state-snapshot.js
@@ -0,0 +1,60 @@
+const Store = require('electron-store');
+
+class UiStateSnapshotStore {
+ constructor() {
+ this.store = new Store({
+ name: 'ui-state-snapshot',
+ clearInvalidConfig: true
+ });
+ }
+
+ getCollections() {
+ return this.store.get('collections') || [];
+ }
+
+ saveCollections(collections) {
+ this.store.set('collections', collections);
+ }
+
+ getCollectionByPathname({ pathname }) {
+ let collections = this.getCollections();
+
+ let collection = collections.find(c => c?.pathname === pathname);
+ if (!collection) {
+ collection = { pathname };
+ collections.push(collection);
+ this.saveCollections(collections);
+ }
+
+ return collection;
+ }
+
+ setCollectionByPathname({ collection }) {
+ let collections = this.getCollections();
+
+ collections = collections.filter(c => c?.pathname !== collection.pathname);
+ collections.push({ ...collection });
+ this.saveCollections(collections);
+
+ return collection;
+ }
+
+ updateCollectionEnvironment({ collectionPath, environmentName }) {
+ const collection = this.getCollectionByPathname({ pathname: collectionPath });
+ collection.selectedEnvironment = environmentName;
+ this.setCollectionByPathname({ collection });
+ }
+
+ update({ type, data }) {
+ switch(type) {
+ case 'COLLECTION_ENVIRONMENT':
+ const { collectionPath, environmentName } = data;
+ this.updateCollectionEnvironment({ collectionPath, environmentName });
+ break;
+ default:
+ break;
+ }
+ }
+}
+
+module.exports = UiStateSnapshotStore;
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
new file mode 100644
index 000000000..94fa30ec8
--- /dev/null
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -0,0 +1,491 @@
+const { get, each, find, compact, isString, filter } = require('lodash');
+const fs = require('fs');
+const { getRequestUid } = require('../cache/requestUids');
+const { uuid } = require('./common');
+const os = require('os');
+
+const mergeHeaders = (collection, request, requestTreePath) => {
+ let headers = new Map();
+
+ let collectionHeaders = get(collection, 'root.request.headers', []);
+ collectionHeaders.forEach((header) => {
+ if (header.enabled) {
+ if (header?.name?.toLowerCase?.() === 'content-type') {
+ headers.set('content-type', header.value);
+ } else {
+ headers.set(header.name, header.value);
+ }
+ }
+ });
+
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let _headers = get(i, 'root.request.headers', []);
+ _headers.forEach((header) => {
+ if (header.enabled) {
+ if (header.name.toLowerCase() === 'content-type') {
+ headers.set('content-type', header.value);
+ } else {
+ headers.set(header.name, header.value);
+ }
+ }
+ });
+ } else {
+ const _headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
+ _headers.forEach((header) => {
+ if (header.enabled) {
+ if (header.name.toLowerCase() === 'content-type') {
+ headers.set('content-type', header.value);
+ } else {
+ headers.set(header.name, header.value);
+ }
+ }
+ });
+ }
+ }
+
+ request.headers = Array.from(headers, ([name, value]) => ({ name, value, enabled: true }));
+};
+
+const mergeVars = (collection, request, requestTreePath) => {
+ let reqVars = new Map();
+ let collectionRequestVars = get(collection, 'root.request.vars.req', []);
+ let collectionVariables = {};
+ collectionRequestVars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ collectionVariables[_var.name] = _var.value;
+ }
+ });
+ let folderVariables = {};
+ let requestVariables = {};
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let vars = get(i, 'root.request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ folderVariables[_var.name] = _var.value;
+ }
+ });
+ } else {
+ const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ reqVars.set(_var.name, _var.value);
+ requestVariables[_var.name] = _var.value;
+ }
+ });
+ }
+ }
+
+ request.collectionVariables = collectionVariables;
+ request.folderVariables = folderVariables;
+ request.requestVariables = requestVariables;
+
+ if(request?.vars) {
+ request.vars.req = Array.from(reqVars, ([name, value]) => ({
+ name,
+ value,
+ enabled: true,
+ type: 'request'
+ }));
+ }
+
+ let resVars = new Map();
+ let collectionResponseVars = get(collection, 'root.request.vars.res', []);
+ collectionResponseVars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let vars = get(i, 'root.request.vars.res', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ } else {
+ const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
+ vars.forEach((_var) => {
+ if (_var.enabled) {
+ resVars.set(_var.name, _var.value);
+ }
+ });
+ }
+ }
+
+ if(request?.vars) {
+ request.vars.res = Array.from(resVars, ([name, value]) => ({
+ name,
+ value,
+ enabled: true,
+ type: 'response'
+ }));
+ }
+};
+
+const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
+ let collectionPreReqScript = get(collection, 'root.request.script.req', '');
+ let collectionPostResScript = get(collection, 'root.request.script.res', '');
+ let collectionTests = get(collection, 'root.request.tests', '');
+
+ let combinedPreReqScript = [];
+ let combinedPostResScript = [];
+ let combinedTests = [];
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ let preReqScript = get(i, 'root.request.script.req', '');
+ if (preReqScript && preReqScript.trim() !== '') {
+ combinedPreReqScript.push(preReqScript);
+ }
+
+ let postResScript = get(i, 'root.request.script.res', '');
+ if (postResScript && postResScript.trim() !== '') {
+ combinedPostResScript.push(postResScript);
+ }
+
+ let tests = get(i, 'root.request.tests', '');
+ if (tests && tests?.trim?.() !== '') {
+ combinedTests.push(tests);
+ }
+ }
+ }
+
+ request.script.req = compact([collectionPreReqScript, ...combinedPreReqScript, request?.script?.req || '']).join(os.EOL);
+
+ if (scriptFlow === 'sequential') {
+ request.script.res = compact([collectionPostResScript, ...combinedPostResScript, request?.script?.res || '']).join(os.EOL);
+ } else {
+ request.script.res = compact([request?.script?.res || '', ...combinedPostResScript.reverse(), collectionPostResScript]).join(os.EOL);
+ }
+
+ if (scriptFlow === 'sequential') {
+ request.tests = compact([collectionTests, ...combinedTests, request?.tests || '']).join(os.EOL);
+ } else {
+ request.tests = compact([request?.tests || '', ...combinedTests.reverse(), collectionTests]).join(os.EOL);
+ }
+};
+
+const flattenItems = (items = []) => {
+ const flattenedItems = [];
+
+ const flatten = (itms, flattened) => {
+ each(itms, (i) => {
+ flattened.push(i);
+
+ if (i.items && i.items.length) {
+ flatten(i.items, flattened);
+ }
+ });
+ };
+
+ flatten(items, flattenedItems);
+
+ return flattenedItems;
+};
+
+const findItem = (items = [], itemUid) => {
+ return find(items, (i) => i.uid === itemUid);
+};
+
+const findItemInCollection = (collection, itemUid) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return findItem(flattenedItems, itemUid);
+};
+
+const findParentItemInCollection = (collection, itemUid) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return find(flattenedItems, (item) => {
+ return item.items && find(item.items, (i) => i.uid === itemUid);
+ });
+};
+
+const findParentItemInCollectionByPathname = (collection, pathname) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return find(flattenedItems, (item) => {
+ return item.items && find(item.items, (i) => i.pathname === pathname);
+ });
+};
+
+const getTreePathFromCollectionToItem = (collection, _item) => {
+ let path = [];
+ let item = findItemInCollection(collection, _item.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item.uid);
+ }
+ 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', []);
+ const file = get(request, 'request.body.file', []);
+
+ 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()));
+ file.forEach((param) => (param.uid = uuid()));
+
+ return request;
+};
+
+const findItemByPathname = (items = [], pathname) => {
+ return find(items, (i) => i.pathname === pathname);
+};
+
+const findItemInCollectionByPathname = (collection, pathname) => {
+ let flattenedItems = flattenItems(collection.items);
+
+ return findItemByPathname(flattenedItems, pathname);
+};
+
+const replaceTabsWithSpaces = (str, numSpaces = 2) => {
+ if (!str || !str.length || !isString(str)) {
+ return '';
+ }
+
+ return str.replaceAll('\t', ' '.repeat(numSpaces));
+};
+
+const transformRequestToSaveToFilesystem = (item) => {
+ const _item = item.draft ? item.draft : item;
+ const itemToSave = {
+ uid: _item.uid,
+ type: _item.type,
+ name: _item.name,
+ seq: _item.seq,
+ request: {
+ method: _item.request.method,
+ url: _item.request.url,
+ params: [],
+ headers: [],
+ auth: _item.request.auth,
+ body: _item.request.body,
+ script: _item.request.script,
+ vars: _item.request.vars,
+ assertions: _item.request.assertions,
+ tests: _item.request.tests,
+ docs: _item.request.docs
+ }
+ };
+
+ each(_item.request.params, (param) => {
+ itemToSave.request.params.push({
+ uid: param.uid,
+ name: param.name,
+ value: param.value,
+ description: param.description,
+ type: param.type,
+ enabled: param.enabled
+ });
+ });
+
+ each(_item.request.headers, (header) => {
+ itemToSave.request.headers.push({
+ uid: header.uid,
+ name: header.name,
+ value: header.value,
+ description: header.description,
+ enabled: header.enabled
+ });
+ });
+
+ if (itemToSave.request.body.mode === 'json') {
+ itemToSave.request.body = {
+ ...itemToSave.request.body,
+ json: replaceTabsWithSpaces(itemToSave.request.body.json)
+ };
+ }
+
+ return itemToSave;
+}
+
+const sortCollection = (collection) => {
+ const items = collection.items || [];
+ let folderItems = filter(items, (item) => item.type === 'folder');
+ let requestItems = filter(items, (item) => item.type !== 'folder');
+
+ folderItems = folderItems.sort((a, b) => a.seq - b.seq);
+ requestItems = requestItems.sort((a, b) => a.seq - b.seq);
+
+ collection.items = folderItems.concat(requestItems);
+
+ each(folderItems, (item) => {
+ sortCollection(item);
+ });
+};
+
+const sortFolder = (folder = {}) => {
+ const items = folder.items || [];
+ let folderItems = filter(items, (item) => item.type === 'folder');
+ let requestItems = filter(items, (item) => item.type !== 'folder');
+
+ folderItems = folderItems.sort((a, b) => a.seq - b.seq);
+ requestItems = requestItems.sort((a, b) => a.seq - b.seq);
+
+ folder.items = folderItems.concat(requestItems);
+
+ each(folderItems, (item) => {
+ sortFolder(item);
+ });
+
+ return folder;
+};
+
+const getAllRequestsInFolderRecursively = (folder = {}) => {
+ let requests = [];
+
+ if (folder.items && folder.items.length) {
+ folder.items.forEach((item) => {
+ if (item.type !== 'folder') {
+ requests.push(item);
+ } else {
+ requests = requests.concat(getAllRequestsInFolderRecursively(item));
+ }
+ });
+ }
+
+ return requests;
+};
+
+const getEnvVars = (environment = {}) => {
+ const variables = environment.variables;
+ if (!variables || !variables.length) {
+ return {
+ __name__: environment.name
+ };
+ }
+
+ const envVars = {};
+ each(variables, (variable) => {
+ if (variable.enabled) {
+ envVars[variable.name] = variable.value;
+ }
+ });
+
+ return {
+ ...envVars,
+ __name__: environment.name
+ };
+};
+
+const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = [] }) => {
+ let credentialsVariables = {};
+ oauth2Credentials.forEach(({ credentialsId, credentials }) => {
+ if (credentials) {
+ Object.entries(credentials).forEach(([key, value]) => {
+ credentialsVariables[`$oauth2.${credentialsId}.${key}`] = value;
+ });
+ }
+ });
+ return credentialsVariables;
+};
+
+const mergeAuth = (collection, request, requestTreePath) => {
+ // Start with collection level auth (always consider collection auth as base)
+ let collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
+ let effectiveAuth = collectionAuth;
+ let lastFolderWithAuth = null;
+
+ // Traverse through the path to find the closest auth configuration
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ // Only consider folders that have a valid auth mode
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveAuth = folderAuth;
+ lastFolderWithAuth = i;
+ }
+ }
+ }
+
+ // If request is set to inherit, use the effective auth from collection/folders
+ if (request.auth.mode === 'inherit') {
+ request.auth = effectiveAuth;
+
+ // For OAuth2, we need to handle credentials properly
+ if (effectiveAuth.mode === 'oauth2') {
+ if (lastFolderWithAuth) {
+ // If auth is from folder, add folderUid and clear itemUid
+ request.oauth2Credentials = {
+ ...request.oauth2Credentials,
+ folderUid: lastFolderWithAuth.uid,
+ itemUid: null,
+ mode: request.auth.mode
+ };
+ } else {
+ // If auth is from collection, ensure no folderUid and no itemUid
+ request.oauth2Credentials = {
+ ...request.oauth2Credentials,
+ folderUid: null,
+ itemUid: null,
+ mode: request.auth.mode
+ };
+ }
+ }
+ }
+};
+
+module.exports = {
+ mergeHeaders,
+ mergeVars,
+ mergeScripts,
+ mergeAuth,
+ getTreePathFromCollectionToItem,
+ flattenItems,
+ findItem,
+ findItemInCollection,
+ findItemByPathname,
+ findItemInCollectionByPathname,
+ findParentItemInCollection,
+ findParentItemInCollectionByPathname,
+ parseBruFileMeta,
+ hydrateRequestWithUuid,
+ transformRequestToSaveToFilesystem,
+ sortCollection,
+ sortFolder,
+ getAllRequestsInFolderRecursively,
+ getEnvVars,
+ getFormattedCollectionOauth2Credentials
+};
\ No newline at end of file
diff --git a/packages/bruno-electron/src/utils/common.js b/packages/bruno-electron/src/utils/common.js
index 50b17bb38..a855e5523 100644
--- a/packages/bruno-electron/src/utils/common.js
+++ b/packages/bruno-electron/src/utils/common.js
@@ -1,4 +1,6 @@
const { customAlphabet } = require('nanoid');
+const iconv = require('iconv-lite');
+const { cloneDeep } = require('lodash');
// a customized version of nanoid without using _ and -
const uuid = () => {
@@ -25,10 +27,24 @@ const parseJson = async (obj) => {
}
};
-const safeStringifyJSON = (data) => {
+const getCircularReplacer = () => {
+ const seen = new WeakSet();
+ return (key, value) => {
+ if (typeof value === "object" && value !== null) {
+ if (seen.has(value)) return "[Circular]";
+ seen.add(value);
+ }
+ return value;
+ };
+};
+
+const safeStringifyJSON = (data, indent = null) => {
+ if (data === undefined) return undefined;
try {
- return JSON.stringify(data);
+ // getCircularReplacer - removes circular references that cause an error when stringifying
+ return JSON.stringify(data, getCircularReplacer(), indent);
} catch (e) {
+ console.warn('Failed to stringify data:', e.message);
return data;
}
};
@@ -85,6 +101,42 @@ const flattenDataForDotNotation = (data) => {
return result;
};
+const parseDataFromResponse = (response, disableParsingResponseJson = false) => {
+ // Parse the charset from content type: https://stackoverflow.com/a/33192813
+ const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(response.headers['content-type'] || '');
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec#using_exec_with_regexp_literals
+ const charsetValue = charsetMatch?.[1];
+ const dataBuffer = Buffer.from(response.data);
+ // Overwrite the original data for backwards compatibility
+ let data;
+ if (iconv.encodingExists(charsetValue)) {
+ data = iconv.decode(dataBuffer, charsetValue);
+ } else {
+ data = iconv.decode(dataBuffer, 'utf-8');
+ }
+ // Try to parse response to JSON, this can quietly fail
+ try {
+ // Filter out ZWNBSP character
+ // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
+ data = data.replace(/^\uFEFF/, '');
+ if (!disableParsingResponseJson) {
+ data = JSON.parse(data);
+ }
+ } catch { }
+
+ return { data, dataBuffer };
+};
+
+const parseDataFromRequest = (request) => {
+ const requestDataString = request.mode == 'file'? "": (typeof request?.data === 'string' ? request?.data : safeStringifyJSON(request?.data));
+ const requestCopy = cloneDeep(request);
+ if (!requestCopy.data) {
+ return { data: null, dataBuffer: null };
+ }
+ requestCopy.data = requestDataString;
+ return parseDataFromResponse(requestCopy);
+};
+
module.exports = {
uuid,
stringifyJson,
@@ -93,5 +145,7 @@ module.exports = {
safeParseJSON,
simpleHash,
generateUidBasedOnHash,
- flattenDataForDotNotation
+ flattenDataForDotNotation,
+ parseDataFromResponse,
+ parseDataFromRequest
};
diff --git a/packages/bruno-electron/src/utils/cookies.js b/packages/bruno-electron/src/utils/cookies.js
index 5b4d7fc7c..7f3751eaf 100644
--- a/packages/bruno-electron/src/utils/cookies.js
+++ b/packages/bruno-electron/src/utils/cookies.js
@@ -1,5 +1,7 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
+const moment = require('moment');
+const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
@@ -11,7 +13,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => {
};
const getCookiesForUrl = (url) => {
- return cookieJar.getCookiesSync(url);
+ return cookieJar.getCookiesSync(url, {
+ secure: isPotentiallyTrustworthyOrigin(url)
+ });
};
const getCookieStringForUrl = (url) => {
@@ -64,22 +68,130 @@ const getDomainsWithCookies = () => {
});
};
-const deleteCookiesForDomain = (domain) => {
+const deleteCookie = (domain, path, cookieKey) => {
return new Promise((resolve, reject) => {
+ cookieJar.store.removeCookie(domain, path, cookieKey, (err) => {
+ if (err) {
+ return reject(err);
+ }
+ return resolve();
+ });
+ });
+};
+
+const deleteCookiesForDomain = (domain) => {
+ return new Promise((resolve, reject) => {
cookieJar.store.removeCookies(domain, null, (err) => {
if (err) {
return reject(err);
}
-
return resolve();
});
});
};
+const updateCookieObj = (cookieObj, oldCookie) => {
+ return {
+ ...cookieObj,
+ // Preserve immutable properties from old cookie
+ path: oldCookie.path,
+ key: oldCookie.key,
+ domain: oldCookie.domain,
+ // Handle other mutable properties
+ expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
+ creation: oldCookie?.creation && moment(oldCookie.creation).isValid() ? new Date(oldCookie.creation) : new Date(),
+ lastAccessed:
+ oldCookie?.lastAccessed && moment(oldCookie.lastAccessed).isValid()
+ ? new Date(oldCookie.lastAccessed)
+ : new Date()
+ };
+};
+
+const createCookieObj = (cookieObj) => {
+ return {
+ ...cookieObj,
+ path: cookieObj.path || '/',
+ expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
+ creation: cookieObj?.creation && moment(cookieObj.creation).isValid() ? new Date(cookieObj.creation) : new Date(),
+ lastAccessed:
+ cookieObj?.lastAccessed && moment(cookieObj.lastAccessed).isValid()
+ ? new Date(cookieObj.lastAccessed)
+ : new Date()
+ };
+};
+
+const addCookieForDomain = (domain, cookieObj) => {
+ return new Promise((resolve, reject) => {
+ try {
+ const cookie = new Cookie(createCookieObj(cookieObj));
+ cookieJar.store.putCookie(cookie, (err) => {
+ if (err) {
+ return reject(err);
+ }
+ return resolve();
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+};
+
+const modifyCookieForDomain = (domain, oldCookieObj, cookieObj) => {
+ return new Promise((resolve, reject) => {
+ try {
+ const oldCookie = new Cookie(createCookieObj(oldCookieObj));
+ const newCookie = new Cookie(updateCookieObj(cookieObj, oldCookie));
+ cookieJar.store.updateCookie(oldCookie, newCookie, (removeErr) => {
+ if (removeErr) {
+ return reject(removeErr);
+ }
+ return resolve();
+ });
+ } catch (err) {
+ reject(err);
+ }
+ });
+};
+
+const parseCookieString = (cookieStr) => {
+ try {
+ const cookie = Cookie.parse(cookieStr);
+ if (!cookie) return null;
+
+ return {
+ ...cookie,
+ expires: cookie.expires === Infinity ? null : cookie.expires
+ };
+ } catch (err) {
+ throw new Error(err);
+ }
+};
+
+const createCookieString = (cookieObj) => {
+ const cookie = new Cookie(createCookieObj(cookieObj));
+
+ // cookie.toString() omits the domain
+ let cookieString = cookie.toString();
+
+ // Manually append domain and hostOnly if they exist
+ if (cookieObj.hostOnly && !cookieString.includes('Domain=')) {
+ cookieString += `; Domain=${cookieObj.domain}`;
+ }
+
+ return cookieString;
+};
+
module.exports = {
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
- deleteCookiesForDomain
+ deleteCookie,
+ deleteCookiesForDomain,
+ addCookieForDomain,
+ modifyCookieForDomain,
+ parseCookieString,
+ createCookieString,
+ updateCookieObj,
+ createCookieObj
};
diff --git a/packages/bruno-electron/src/utils/encryption.js b/packages/bruno-electron/src/utils/encryption.js
index b73e437e6..7e7b0b4b7 100644
--- a/packages/bruno-electron/src/utils/encryption.js
+++ b/packages/bruno-electron/src/utils/encryption.js
@@ -1,15 +1,39 @@
const crypto = require('crypto');
-const { machineIdSync } = require('node-machine-id');
+const { machineIdSync } = require('@usebruno/node-machine-id');
const { safeStorage } = require('electron');
// Constants for algorithm identification
const ELECTRONSAFESTORAGE_ALGO = '00';
const AES256_ALGO = '01';
-// AES-256 encryption and decryption functions
+function deriveKeyAndIv(password, keyLength, ivLength) {
+ const key = Buffer.alloc(keyLength);
+ const iv = Buffer.alloc(ivLength);
+ const derivedBytes = [];
+ let lastHash = null;
+
+ while (Buffer.concat(derivedBytes).length < keyLength + ivLength) {
+ const hash = crypto.createHash('md5');
+ if (lastHash) {
+ hash.update(lastHash);
+ }
+ hash.update(Buffer.from(password, 'utf8'));
+ lastHash = hash.digest();
+ derivedBytes.push(lastHash);
+ }
+
+ const concatenatedBytes = Buffer.concat(derivedBytes);
+ concatenatedBytes.copy(key, 0, 0, keyLength);
+ concatenatedBytes.copy(iv, 0, keyLength, keyLength + ivLength);
+
+ return { key, iv };
+}
+
function aes256Encrypt(data) {
- const key = machineIdSync();
- const cipher = crypto.createCipher('aes-256-cbc', key);
+ const rawKey = machineIdSync();
+ const iv = Buffer.alloc(16, 0); // Default IV for new encryption
+ const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
@@ -17,14 +41,28 @@ function aes256Encrypt(data) {
}
function aes256Decrypt(data) {
- const key = machineIdSync();
- const decipher = crypto.createDecipher('aes-256-cbc', key);
- let decrypted = decipher.update(data, 'hex', 'utf8');
- decrypted += decipher.final('utf8');
+ const rawKey = machineIdSync();
- return decrypted;
+ // Attempt to decrypt using new method first
+ const iv = Buffer.alloc(16, 0); // Default IV for new encryption
+ const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key
+
+ try {
+ const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
+ let decrypted = decipher.update(data, 'hex', 'utf8');
+ decrypted += decipher.final('utf8');
+ return decrypted;
+ } catch (err) {
+ // If decryption fails, fall back to old key derivation
+ const { key: oldKey, iv: oldIv } = deriveKeyAndIv(rawKey, 32, 16);
+ const decipher = crypto.createDecipheriv('aes-256-cbc', oldKey, oldIv);
+ let decrypted = decipher.update(data, 'hex', 'utf8');
+ decrypted += decipher.final('utf8');
+ return decrypted;
+ }
}
+
// electron safe storage encryption and decryption functions
function safeStorageEncrypt(str) {
let encryptedStringBuffer = safeStorage.encryptString(str);
@@ -51,6 +89,9 @@ function encryptString(str) {
if (typeof str !== 'string') {
throw new Error('Encrypt failed: invalid string');
}
+ if (str.length === 0) {
+ return '';
+ }
let encryptedString = '';
@@ -66,9 +107,12 @@ function encryptString(str) {
}
function decryptString(str) {
- if (!str) {
+ if (typeof str !== 'string') {
throw new Error('Decrypt failed: unrecognized string format');
}
+ if (str.length === 0) {
+ return '';
+ }
// Find the index of the first colon
const colonIndex = str.indexOf(':');
@@ -86,7 +130,11 @@ function decryptString(str) {
}
if (algo === ELECTRONSAFESTORAGE_ALGO) {
- return safeStorageDecrypt(encryptedString);
+ if (safeStorage && safeStorage.isEncryptionAvailable()) {
+ return safeStorageDecrypt(encryptedString);
+ } else {
+ return '';
+ }
}
if (algo === AES256_ALGO) {
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index 8216bd9c9..28b479013 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -3,6 +3,7 @@ const fs = require('fs-extra');
const fsPromises = require('fs/promises');
const { dialog } = require('electron');
const isValidPathname = require('is-valid-path');
+const os = require('os');
const exists = async (p) => {
try {
@@ -37,7 +38,17 @@ const isDirectory = (dirPath) => {
}
};
+const hasSubDirectories = (dir) => {
+ const files = fs.readdirSync(dir);
+ return files.some(file => fs.statSync(path.join(dir, file)).isDirectory());
+};
+
const normalizeAndResolvePath = (pathname) => {
+
+ if (isWSLPath(pathname)) {
+ return normalizeWSLPath(pathname);
+ }
+
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
@@ -50,20 +61,27 @@ const normalizeAndResolvePath = (pathname) => {
return path.resolve(pathname);
};
-const writeFile = async (pathname, content) => {
+function isWSLPath(pathname) {
+ // Check if the path starts with the WSL prefix
+ // eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru"
+ return pathname.startsWith('\\\\') || pathname.startsWith('//') || pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost');
+
+}
+
+function normalizeWSLPath(pathname) {
+ // Replace the WSL path prefix and convert forward slashes to backslashes
+ // This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion)
+ return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
+}
+
+
+const writeFile = async (pathname, content, isBinary = false) => {
try {
- fs.writeFileSync(pathname, content, {
- encoding: 'utf8'
+ await safeWriteFile(pathname, content, {
+ encoding: !isBinary ? "utf-8" : null
});
} catch (err) {
- return Promise.reject(err);
- }
-};
-
-const writeBinaryFile = async (pathname, content) => {
- try {
- fs.writeFileSync(pathname, content);
- } catch (err) {
+ console.error(`Error writing file at ${pathname}:`, err);
return Promise.reject(err);
}
};
@@ -99,13 +117,13 @@ const browseDirectory = async (win) => {
return false;
}
- const resolvedPath = normalizeAndResolvePath(filePaths[0]);
+ const resolvedPath = path.resolve(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false;
};
-const browseFiles = async (win, filters) => {
+const browseFiles = async (win, filters = [], properties = []) => {
const { filePaths } = await dialog.showOpenDialog(win, {
- properties: ['openFile', 'multiSelections'],
+ properties: ['openFile', ...properties],
filters
});
@@ -113,7 +131,7 @@ const browseFiles = async (win, filters) => {
return [];
}
- return filePaths.map((path) => normalizeAndResolvePath(path)).filter((path) => isFile(path));
+ return filePaths.map((filePath) => path.resolve(filePath)).filter((filePath) => isFile(filePath));
};
const chooseFileToSave = async (win, preferredFileName = '') => {
@@ -143,10 +161,189 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
-const sanitizeDirectoryName = (name) => {
- return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
+const sanitizeName = (name) => {
+ const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
+ name = name
+ .replace(invalidCharacters, '-') // replace invalid characters with hyphens
+ .replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
+ .replace(/[.\s]+$/, ''); // remove trailing dots and spaces
+ return name;
};
+const isWindowsOS = () => {
+ return os.platform() === 'win32';
+}
+
+const validateName = (name) => {
+ const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // keeping this for informational purpose
+ const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
+ const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
+ const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
+ const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
+ if (name.length > 255) return false; // max name length
+
+ if (reservedDeviceNames.test(name)) return false; // windows reserved names
+
+ return (
+ firstCharacter.test(name) &&
+ middleCharacters.test(name) &&
+ lastCharacter.test(name)
+ );
+};
+
+
+const safeToRename = (oldPath, newPath) => {
+ try {
+ // If the new path doesn't exist, it's safe to rename
+ if (!fs.existsSync(newPath)) {
+ return true;
+ }
+
+ const oldStat = fs.statSync(oldPath);
+ const newStat = fs.statSync(newPath);
+
+ if (isWindowsOS()) {
+ // Windows-specific comparison:
+ // Check if both files have the same birth time, size (Since, Win FAT-32 doesn't use inodes)
+
+ return oldStat.birthtimeMs === newStat.birthtimeMs && oldStat.size === newStat.size;
+ }
+ // Unix/Linux/MacOS: Check inode to see if they are the same file
+ return oldStat.ino === newStat.ino;
+ } catch (error) {
+ console.error(`Error checking file rename safety for ${oldPath} and ${newPath}:`, error);
+ return false;
+ }
+};
+
+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);
+}
+
+const getSafePathToWrite = (filePath) => {
+ const MAX_FILENAME_LENGTH = 255; // Common limit on most filesystems
+ let dir = path.dirname(filePath);
+ let ext = path.extname(filePath);
+ let base = path.basename(filePath, ext);
+ if (base.length + ext.length > MAX_FILENAME_LENGTH) {
+ base = sanitizeName(base);
+ base = base.slice(0, MAX_FILENAME_LENGTH - ext.length);
+ }
+ let safePath = path.join(dir, base + ext);
+ return safePath;
+}
+
+async function safeWriteFile(filePath, data, options) {
+ const safePath = getSafePathToWrite(filePath);
+ await fs.writeFile(safePath, data, options);
+}
+
+function safeWriteFileSync(filePath, data) {
+ const safePath = getSafePathToWrite(filePath);
+ fs.writeFileSync(safePath, data);
+}
+
+// Recursively copies a source to a destination .
+const copyPath = async (source, destination) => {
+ let targetPath = `${destination}/${path.basename(source)}`;
+
+ const targetPathExists = await fsPromises.access(targetPath).then(() => true).catch(() => false);
+ if (targetPathExists) {
+ throw new Error(`Cannot copy, ${path.basename(source)} already exists in ${path.basename(destination)}`);
+ }
+
+ const copy = async (source, destination) => {
+ const stat = await fsPromises.lstat(source);
+ if (stat.isDirectory()) {
+ await fsPromises.mkdir(destination, { recursive: true });
+ const entries = await fsPromises.readdir(source);
+ for (const entry of entries) {
+ const srcPath = path.join(source, entry);
+ const destPath = path.join(destination, entry);
+ await copy(srcPath, destPath);
+ }
+ } else {
+ await fsPromises.copyFile(source, destination);
+ }
+ }
+
+ await copy(source, targetPath);
+}
+
+// Recursively removes a source .
+const removePath = async (source) => {
+ const stat = await fsPromises.lstat(source);
+ if (stat.isDirectory()) {
+ const entries = await fsPromises.readdir(source);
+ for (const entry of entries) {
+ const entryPath = path.join(source, entry);
+ await removePath(entryPath);
+ }
+ await fsPromises.rmdir(source);
+ } else {
+ await fsPromises.unlink(source);
+ }
+}
+
+// Recursively gets paths.
+const getPaths = async (source) => {
+ let paths = [];
+ const _getPaths = async (source) => {
+ const stat = await fsPromises.lstat(source);
+ paths.push(source);
+ if (stat.isDirectory()) {
+ const entries = await fsPromises.readdir(source);
+ for (const entry of entries) {
+ const entryPath = path.join(source, entry);
+ await _getPaths(entryPath);
+ }
+ }
+ }
+ await _getPaths(source);
+ return paths;
+}
+
+
module.exports = {
isValidPathname,
exists,
@@ -154,8 +351,9 @@ module.exports = {
isFile,
isDirectory,
normalizeAndResolvePath,
+ isWSLPath,
+ normalizeWSLPath,
writeFile,
- writeBinaryFile,
hasJsonExtension,
hasBruExtension,
createDirectory,
@@ -164,5 +362,16 @@ module.exports = {
chooseFileToSave,
searchForFiles,
searchForBruFiles,
- sanitizeDirectoryName
+ sanitizeName,
+ isWindowsOS,
+ safeToRename,
+ validateName,
+ hasSubDirectories,
+ getCollectionStats,
+ sizeInMB,
+ safeWriteFile,
+ safeWriteFileSync,
+ copyPath,
+ removePath,
+ getPaths
};
diff --git a/packages/bruno-electron/src/utils/filesystem.test.js b/packages/bruno-electron/src/utils/filesystem.test.js
index 62d7b502f..a0f0f018d 100644
--- a/packages/bruno-electron/src/utils/filesystem.test.js
+++ b/packages/bruno-electron/src/utils/filesystem.test.js
@@ -1,26 +1,88 @@
-const { sanitizeDirectoryName } = require('./filesystem.js');
+const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath } = require('./filesystem.js');
-describe('sanitizeDirectoryName', () => {
+describe('sanitizeName', () => {
it('should replace invalid characters with hyphens', () => {
- const input = '<>:"/\\|?*\x00-\x1F';
- const expectedOutput = '---';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ expect(sanitizeName(
+ 'valid<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F'
+ )).toEqual('valid----------------------------------------');
+
+ expect(sanitizeName(
+ '<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1Fvalid<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F'
+ )).toEqual('valid----------------------------------------');
});
it('should not modify valid directory names', () => {
const input = 'my-directory';
- expect(sanitizeDirectoryName(input)).toEqual(input);
+ expect(sanitizeName(input)).toEqual(input);
});
it('should replace multiple invalid characters with a single hyphen', () => {
const input = 'my<>invalid?directory';
- const expectedOutput = 'my-invalid-directory';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ const expectedOutput = 'my--invalid-directory';
+ expect(sanitizeName(input)).toEqual(expectedOutput);
});
it('should handle names with slashes', () => {
const input = 'my/invalid/directory';
const expectedOutput = 'my-invalid-directory';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ expect(sanitizeName(input)).toEqual(expectedOutput);
+ });
+});
+
+describe('WSL Path Utilities', () => {
+ describe('isWSLPath', () => {
+ it('should identify WSL paths starting with double backslash', () => {
+ expect(isWSLPath('\\\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with double forward slash', () => {
+ expect(isWSLPath('//wsl.localhost/Ubuntu/home/user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with /wsl.localhost/', () => {
+ expect(isWSLPath('/wsl.localhost/Ubuntu/home/user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with \\wsl.localhost', () => {
+ expect(isWSLPath('\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
+ });
+
+ it('should return false for non-WSL paths', () => {
+ expect(isWSLPath('C:\\Users\\user\\Documents')).toBe(false);
+ expect(isWSLPath('/home/user/documents')).toBe(false);
+ expect(isWSLPath('relative/path')).toBe(false);
+ });
+ });
+
+ describe('normalizeWSLPath', () => {
+ it('should convert forward slash WSL paths to backslash format', () => {
+ const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(expected);
+ });
+
+ it('should handle paths already in backslash format', () => {
+ const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(input);
+ });
+
+ it('should convert mixed slash formats to backslash format', () => {
+ const input = '/wsl.localhost\\Ubuntu/home\\user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(expected);
+ });
+ });
+
+ describe('normalizeAndResolvePath with WSL paths', () => {
+ it('should normalize WSL paths', () => {
+ const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeAndResolvePath(input)).toBe(expected);
+ });
+
+ it('should handle already normalized WSL paths', () => {
+ const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeAndResolvePath(input)).toBe(input);
+ });
});
});
diff --git a/packages/bruno-electron/src/utils/form-data.js b/packages/bruno-electron/src/utils/form-data.js
new file mode 100644
index 000000000..dc27b2577
--- /dev/null
+++ b/packages/bruno-electron/src/utils/form-data.js
@@ -0,0 +1,62 @@
+const { forEach } = require('lodash');
+const FormData = require('form-data');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * @param {Array.} params The request body Array
+ * @returns {object} Returns an obj with repeating key as an array of values
+ * {item: 2, item: 3, item1: 4} becomes {item: [2,3], item1: 4}
+ */
+const buildFormUrlEncodedPayload = (params) => {
+ return params.reduce((acc, p) => {
+ if (!acc[p.name]) {
+ acc[p.name] = p.value;
+ } else if (Array.isArray(acc[p.name])) {
+ acc[p.name].push(p.value);
+ } else {
+ acc[p.name] = [acc[p.name], p.value];
+ }
+ return acc;
+ }, {});
+};
+
+
+const createFormData = (data, collectionPath) => {
+ // make axios work in node using form data
+ // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
+ const form = new FormData();
+ forEach(data, (datum) => {
+ const { name, type, value, contentType } = datum;
+ let options = {};
+ if (contentType) {
+ options.contentType = contentType;
+ }
+ if (type === 'text') {
+ if (Array.isArray(value)) {
+ value.forEach((val) => form.append(name, val, options));
+ } else {
+ form.append(name, value, options);
+ }
+ return;
+ }
+
+ if (type === 'file') {
+ const filePaths = value || [];
+ filePaths.forEach((filePath) => {
+ let trimmedFilePath = filePath.trim();
+ if (!path.isAbsolute(trimmedFilePath)) {
+ trimmedFilePath = path.join(collectionPath, trimmedFilePath);
+ }
+ options.filename = path.basename(trimmedFilePath);
+ form.append(name, fs.createReadStream(trimmedFilePath), options);
+ });
+ }
+ });
+ return form;
+};
+
+module.exports = {
+ buildFormUrlEncodedPayload,
+ createFormData
+};
diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js
new file mode 100644
index 000000000..beaee1c21
--- /dev/null
+++ b/packages/bruno-electron/src/utils/oauth2.js
@@ -0,0 +1,735 @@
+const { get, cloneDeep } = require('lodash');
+const crypto = require('crypto');
+const { authorizeUserInWindow } = require('../ipc/network/authorize-user-in-window');
+const Oauth2Store = require('../store/oauth2');
+const { makeAxiosInstance } = require('../ipc/network/axios-instance');
+const { safeParseJSON, safeStringifyJSON } = require('./common');
+const qs = require('qs');
+
+const oauth2Store = new Oauth2Store();
+
+const persistOauth2Credentials = ({ collectionUid, url, credentials, credentialsId }) => {
+ if (credentials?.error || !credentials?.access_token) return;
+ const enhancedCredentials = {
+ ...credentials,
+ created_at: Date.now(),
+ };
+ oauth2Store.updateCredentialsForCollection({ collectionUid, url, credentials: enhancedCredentials, credentialsId });
+};
+
+const clearOauth2Credentials = ({ collectionUid, url, credentialsId }) => {
+ oauth2Store.clearCredentialsForCollection({ collectionUid, url, credentialsId });
+};
+
+const getStoredOauth2Credentials = ({ collectionUid, url, credentialsId }) => {
+ try {
+ const credentials = oauth2Store.getCredentialsForCollection({ collectionUid, url, credentialsId });
+ return credentials;
+ }
+ catch(error) {
+ return null;
+ }
+};
+
+const isTokenExpired = (credentials) => {
+ if (!credentials?.access_token) {
+ return true;
+ }
+ if (!credentials?.expires_in || !credentials.created_at) {
+ return false;
+ }
+ const expiryTime = credentials.created_at + credentials.expires_in * 1000;
+ return Date.now() > expiryTime;
+};
+
+const safeParseJSONBuffer = (data) => {
+ return safeParseJSON(Buffer.isBuffer(data) ? data.toString() : data);
+}
+
+// AUTHORIZATION CODE
+
+const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
+ let codeVerifier = generateCodeVerifier();
+ let codeChallenge = generateCodeChallenge(codeVerifier);
+
+ let requestCopy = cloneDeep(request);
+ const oAuth = get(requestCopy, 'oauth2', {});
+ const {
+ clientId,
+ clientSecret,
+ callbackUrl,
+ scope,
+ pkce,
+ credentialsPlacement,
+ authorizationUrl,
+ credentialsId,
+ autoRefreshToken,
+ autoFetchToken,
+ } = oAuth;
+ const url = requestCopy?.oauth2?.accessTokenUrl;
+ if (!forceFetch) {
+ const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
+
+ if (storedCredentials) {
+ // Token exists
+ if (!isTokenExpired(storedCredentials)) {
+ // Token is valid, use it
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ } else {
+ // Token is expired
+ if (autoRefreshToken && storedCredentials.refresh_token) {
+ // Try to refresh token
+ try {
+ const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
+ return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
+ } catch (error) {
+ // Refresh failed
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else if (autoRefreshToken && !storedCredentials.refresh_token) {
+ // Cannot refresh; try autoFetchToken
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ } else if (!autoRefreshToken && autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else {
+ // No stored credentials
+ if (autoFetchToken && !storedCredentials) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed without token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ }
+
+ // Fetch new token process
+ const { authorizationCode, debugInfo } = await getOAuth2AuthorizationCode(requestCopy, codeChallenge, collectionUid);
+
+ requestCopy.method = 'POST';
+ requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded';
+ requestCopy.headers['Accept'] = 'application/json';
+ if (credentialsPlacement === "basic_auth_header") {
+ requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
+ }
+ const data = {
+ grant_type: 'authorization_code',
+ code: authorizationCode,
+ redirect_uri: callbackUrl,
+ client_id: clientId,
+ };
+ if (clientSecret && credentialsPlacement !== "basic_auth_header") {
+ data.client_secret = clientSecret;
+ }
+ if (pkce) {
+ data['code_verifier'] = codeVerifier;
+ }
+ if (scope) {
+ data.scope = scope;
+ }
+ requestCopy.data = qs.stringify(data);
+ requestCopy.url = url;
+ requestCopy.responseType = 'arraybuffer';
+ try {
+ const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
+ const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
+ let responseInfo, parsedResponseData;
+ try {
+ const response = await axiosInstance(requestCopy);
+ parsedResponseData = safeParseJSONBuffer(response.data);
+ responseInfo = {
+ url: response?.url,
+ status: response?.status,
+ statusText: response?.statusText,
+ headers: response?.headers,
+ data: parsedResponseData,
+ timestamp: Date.now(),
+ timeline: response?.timeline
+ };
+ }
+ catch(error) {
+ if (error.response) {
+ responseInfo = {
+ url: error?.response?.url,
+ status: error?.response?.status,
+ statusText: error?.response?.statusText,
+ headers: error?.response?.headers,
+ data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ timestamp: Date.now(),
+ timeline: error?.response?.timeline,
+ error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ };
+ }
+ else if(error?.code) {
+ responseInfo = {
+ status: '-',
+ statusText: error?.code,
+ headers: error?.config?.headers,
+ data: safeStringifyJSON(error?.errors),
+ timeline: error?.response?.timeline
+ };
+ }
+ }
+ // Ensure debugInfo.data is initialized
+ if (!debugInfo) {
+ debugInfo = { data: [] };
+ } else if (!debugInfo.data) {
+ debugInfo.data = [];
+ }
+
+ // Add the axios request and response info as a main request in debugInfo
+ const axiosMainRequest = {
+ requestId: Date.now().toString(),
+ request: {
+ url: url,
+ method: 'POST',
+ headers: requestCopy?.headers,
+ data: requestCopy?.data,
+ error: null
+ },
+ response: {
+ url: responseInfo?.url,
+ headers: responseInfo?.headers,
+ data: responseInfo?.data,
+ status: responseInfo?.status,
+ statusText: responseInfo?.statusText,
+ error: responseInfo?.error,
+ timeline: responseInfo?.timeline
+ },
+ fromCache: false,
+ completed: true,
+ requests: [], // No sub-requests in this context
+ };
+ debugInfo.data.push(axiosMainRequest);
+
+ parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
+
+ return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
+ } catch (error) {
+ return Promise.reject(error);
+ }
+};
+
+const getOAuth2AuthorizationCode = (request, codeChallenge, collectionUid) => {
+ return new Promise(async (resolve, reject) => {
+ const { oauth2 } = request;
+ const { callbackUrl, clientId, authorizationUrl, scope, state, pkce, accessTokenUrl } = oauth2;
+
+ const authorizationUrlWithQueryParams = new URL(authorizationUrl);
+ authorizationUrlWithQueryParams.searchParams.append('response_type', 'code');
+ authorizationUrlWithQueryParams.searchParams.append('client_id', clientId);
+ if (callbackUrl) {
+ authorizationUrlWithQueryParams.searchParams.append('redirect_uri', callbackUrl);
+ }
+ if (scope) {
+ authorizationUrlWithQueryParams.searchParams.append('scope', scope);
+ }
+ if (pkce) {
+ authorizationUrlWithQueryParams.searchParams.append('code_challenge', codeChallenge);
+ authorizationUrlWithQueryParams.searchParams.append('code_challenge_method', 'S256');
+ }
+ if (state) {
+ authorizationUrlWithQueryParams.searchParams.append('state', state);
+ }
+ try {
+ const authorizeUrl = authorizationUrlWithQueryParams.toString();
+ const { authorizationCode, debugInfo } = await authorizeUserInWindow({
+ authorizeUrl,
+ callbackUrl,
+ session: oauth2Store.getSessionIdOfCollection({ collectionUid, url: accessTokenUrl })
+ });
+ resolve({ authorizationCode, debugInfo });
+ } catch (err) {
+ reject(err);
+ }
+ });
+};
+
+// CLIENT CREDENTIALS
+
+const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
+ let requestCopy = cloneDeep(request);
+ const oAuth = get(requestCopy, 'oauth2', {});
+ const {
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ autoRefreshToken,
+ autoFetchToken,
+ } = oAuth;
+
+ const url = requestCopy?.oauth2?.accessTokenUrl;
+
+ if (!forceFetch) {
+ const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
+
+ if (storedCredentials) {
+ // Token exists
+ if (!isTokenExpired(storedCredentials)) {
+ // Token is valid, use it
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ } else {
+ // Token is expired
+ if (autoRefreshToken && storedCredentials.refresh_token) {
+ // Try to refresh token
+ try {
+ const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
+ return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
+ } catch (error) {
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else if (autoRefreshToken && !storedCredentials.refresh_token) {
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ } else if (!autoRefreshToken && autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else {
+ // No stored credentials
+ if (autoFetchToken && !storedCredentials) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed without token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ }
+
+ // Fetch new token process
+ requestCopy.method = 'POST';
+ requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded';
+ requestCopy.headers['Accept'] = 'application/json';
+ if (credentialsPlacement === "basic_auth_header") {
+ requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
+ }
+ const data = {
+ grant_type: 'client_credentials',
+ client_id: clientId,
+ };
+ if (clientSecret && credentialsPlacement !== "basic_auth_header") {
+ data.client_secret = clientSecret;
+ }
+ if (scope) {
+ data.scope = scope;
+ }
+ requestCopy.data = qs.stringify(data);
+ requestCopy.url = url;
+ requestCopy.responseType = 'arraybuffer';
+ let debugInfo = { data: [] };
+ try {
+ const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
+ const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
+ let responseInfo, parsedResponseData;
+ try {
+ const response = await axiosInstance(requestCopy);
+ parsedResponseData = safeParseJSONBuffer(response.data);
+ responseInfo = {
+ url: response?.url,
+ status: response?.status,
+ statusText: response?.statusText,
+ headers: response?.headers,
+ data: parsedResponseData,
+ timestamp: Date.now(),
+ timeline: response?.timeline
+ };
+ }
+ catch(error) {
+ if (error.response) {
+ responseInfo = {
+ url: error?.response?.url,
+ status: error?.response?.status,
+ statusText: error?.response?.statusText,
+ headers: error?.response?.headers,
+ data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ timestamp: Date.now(),
+ timeline: error?.response?.timeline,
+ error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ };
+ }
+ else if(error?.code) {
+ responseInfo = {
+ status: '-',
+ statusText: error?.code,
+ headers: error?.config?.headers,
+ data: safeStringifyJSON(error?.errors),
+ timeline: error?.response?.timeline
+ };
+ }
+ }
+ if (!debugInfo) {
+ debugInfo = { data: [] };
+ } else if (!debugInfo.data) {
+ debugInfo.data = [];
+ }
+
+ // Add the axios request and response info as a main request in debugInfo
+ const axiosMainRequest = {
+ requestId: Date.now().toString(),
+ request: {
+ url: url,
+ method: 'POST',
+ headers: requestCopy?.headers,
+ data: requestCopy?.data,
+ error: null
+ },
+ response: {
+ url: responseInfo?.url,
+ headers: responseInfo?.headers,
+ data: responseInfo?.data,
+ status: responseInfo?.status,
+ statusText: responseInfo?.statusText,
+ error: responseInfo?.error,
+ timeline: responseInfo?.timeline
+ },
+ fromCache: false,
+ completed: true,
+ requests: [], // No sub-requests in this context
+ };
+ debugInfo.data.push(axiosMainRequest);
+
+ parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
+ return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
+ } catch (error) {
+ return Promise.reject(safeStringifyJSON(error?.response?.data));
+ }
+};
+
+// PASSWORD CREDENTIALS
+
+const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => {
+ let requestCopy = cloneDeep(request);
+ const oAuth = get(requestCopy, 'oauth2', {});
+ const {
+ username,
+ password,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ autoRefreshToken,
+ autoFetchToken,
+ } = oAuth;
+ const url = requestCopy?.oauth2?.accessTokenUrl;
+
+ if (!forceFetch) {
+ const storedCredentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
+
+ if (storedCredentials) {
+ // Token exists
+ if (!isTokenExpired(storedCredentials)) {
+ // Token is valid, use it
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ } else {
+ // Token is expired
+ if (autoRefreshToken && storedCredentials.refresh_token) {
+ // Try to refresh token
+ try {
+ const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig });
+ return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId };
+ } catch (error) {
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else if (autoRefreshToken && !storedCredentials.refresh_token) {
+ // Cannot refresh; try autoFetchToken
+ if (autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ } else if (!autoRefreshToken && autoFetchToken) {
+ // Proceed to fetch new token
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ } else {
+ // Proceed with expired token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ } else {
+ // No stored credentials
+ if (autoFetchToken && !storedCredentials) {
+ // Proceed to fetch new token
+ } else {
+ // Proceed without token
+ return { collectionUid, url, credentials: storedCredentials, credentialsId };
+ }
+ }
+ }
+
+ // Fetch new token process
+ requestCopy.method = 'POST';
+ requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded';
+ requestCopy.headers['Accept'] = 'application/json';
+ if (credentialsPlacement === "basic_auth_header") {
+ requestCopy.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
+ }
+ const data = {
+ grant_type: 'password',
+ username,
+ password,
+ client_id: clientId,
+ };
+ if (clientSecret && credentialsPlacement !== "basic_auth_header") {
+ data.client_secret = clientSecret;
+ }
+ if (scope) {
+ data.scope = scope;
+ }
+ requestCopy.data = qs.stringify(data);
+ requestCopy.url = url;
+ requestCopy.responseType = 'arraybuffer';
+ let debugInfo = { data: [] };
+ try {
+ const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
+ const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
+ let responseInfo, parsedResponseData;
+ try {
+ const response = await axiosInstance(requestCopy);
+ parsedResponseData = safeParseJSONBuffer(response.data);
+ responseInfo = {
+ url: response?.url,
+ status: response?.status,
+ statusText: response?.statusText,
+ headers: response?.headers,
+ data: parsedResponseData,
+ timestamp: Date.now(),
+ timeline: response?.timeline
+ };
+ }
+ catch(error) {
+ if (error.response) {
+ responseInfo = {
+ url: error?.response?.url,
+ status: error?.response?.status,
+ statusText: error?.response?.statusText,
+ headers: error?.response?.headers,
+ data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ timestamp: Date.now(),
+ timeline: error?.response?.timeline,
+ error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ };
+ }
+ else if(error?.code) {
+ responseInfo = {
+ status: '-',
+ statusText: error?.code,
+ headers: error?.config?.headers,
+ data: safeStringifyJSON(error?.errors),
+ timeline: error?.response?.timeline
+ };
+ }
+ }
+ if (!debugInfo) {
+ debugInfo = { data: [] };
+ } else if (!debugInfo.data) {
+ debugInfo.data = [];
+ }
+
+ // Add the axios request and response info as a main request in debugInfo
+ const axiosMainRequest = {
+ requestId: Date.now().toString(),
+ request: {
+ url: url,
+ method: 'POST',
+ headers: requestCopy?.headers,
+ data: requestCopy?.data,
+ error: null
+ },
+ response: {
+ url: responseInfo?.url,
+ headers: responseInfo?.headers,
+ data: responseInfo?.data,
+ status: responseInfo?.status,
+ statusText: responseInfo?.statusText,
+ error: responseInfo?.error,
+ timeline: responseInfo?.timeline
+ },
+ fromCache: false,
+ completed: true,
+ requests: [], // No sub-requests in this context
+ };
+ debugInfo.data.push(axiosMainRequest);
+
+ parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
+ return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
+ } catch (error) {
+ return Promise.reject(safeStringifyJSON(error?.response?.data));
+ }
+};
+
+const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyConfig }) => {
+ const oAuth = get(requestCopy, 'oauth2', {});
+ const { clientId, clientSecret, credentialsId } = oAuth;
+ const url = oAuth.refreshTokenUrl ? oAuth.refreshTokenUrl : oAuth.accessTokenUrl;
+
+ const credentials = getStoredOauth2Credentials({ collectionUid, url, credentialsId });
+ if (!credentials?.refresh_token) {
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ // Proceed without token
+ return { collectionUid, url, credentials: null, credentialsId };
+ } else {
+ const data = {
+ grant_type: 'refresh_token',
+ client_id: clientId,
+ refresh_token: credentials.refresh_token,
+ };
+ if (clientSecret) {
+ data.client_secret = clientSecret;
+ }
+ requestCopy.method = 'POST';
+ requestCopy.headers['content-type'] = 'application/x-www-form-urlencoded';
+ requestCopy.headers['Accept'] = 'application/json';
+ requestCopy.data = qs.stringify(data);
+ requestCopy.url = url;
+ requestCopy.responseType = 'arraybuffer';
+ let debugInfo = { data: [] };
+ try {
+ const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
+ const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
+ let responseInfo, parsedResponseData;
+ try {
+ const response = await axiosInstance(requestCopy);
+ parsedResponseData = safeParseJSONBuffer(response.data);
+ responseInfo = {
+ url: response?.url,
+ status: response?.status,
+ statusText: response?.statusText,
+ headers: response?.headers,
+ data: parsedResponseData,
+ timestamp: Date.now(),
+ timeline: response?.timeline
+ };
+ }
+ catch(error) {
+ if (error.response) {
+ responseInfo = {
+ url: error?.response?.url,
+ status: error?.response?.status,
+ statusText: error?.response?.statusText,
+ headers: error?.response?.headers,
+ data: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ timestamp: Date.now(),
+ timeline: error?.response?.timeline,
+ error: safeStringifyJSON(safeParseJSONBuffer(error?.response?.data)),
+ };
+ }
+ else if(error?.code) {
+ responseInfo = {
+ status: '-',
+ statusText: error?.code,
+ headers: error?.config?.headers,
+ data: safeStringifyJSON(error?.errors),
+ timeline: error?.response?.timeline
+ };
+ }
+ }
+ if (!debugInfo) {
+ debugInfo = { data: [] };
+ } else if (!debugInfo.data) {
+ debugInfo.data = [];
+ }
+
+ // Add the axios request and response info as a main request in debugInfo
+ const axiosMainRequest = {
+ requestId: Date.now().toString(),
+ request: {
+ url: url,
+ method: 'POST',
+ headers: requestCopy?.headers,
+ data: requestCopy?.data,
+ error: null
+ },
+ response: {
+ url: responseInfo?.url,
+ headers: responseInfo?.headers,
+ data: responseInfo?.data,
+ status: responseInfo?.status,
+ statusText: responseInfo?.statusText,
+ error: responseInfo?.error,
+ timeline: responseInfo?.timeline
+ },
+ fromCache: false,
+ completed: true,
+ requests: [], // No sub-requests in this context
+ };
+ debugInfo.data.push(axiosMainRequest);
+ if (!parsedResponseData || parsedResponseData?.error) {
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ return { collectionUid, url, credentials: null, credentialsId, debugInfo };
+ }
+ parsedResponseData && persistOauth2Credentials({ collectionUid, url, credentials: parsedResponseData, credentialsId });
+ return { collectionUid, url, credentials: parsedResponseData, credentialsId, debugInfo };
+ } catch (error) {
+ clearOauth2Credentials({ collectionUid, url, credentialsId });
+ // Proceed without token
+ return { collectionUid, url, credentials: null, credentialsId, debugInfo };
+ }
+ }
+};
+
+// HELPER FUNCTIONS
+
+const generateCodeVerifier = () => {
+ return crypto.randomBytes(22).toString('hex');
+};
+
+const generateCodeChallenge = (codeVerifier) => {
+ const hash = crypto.createHash('sha256');
+ hash.update(codeVerifier);
+ const base64Hash = hash.digest('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+ return base64Hash;
+};
+
+module.exports = {
+ getOAuth2TokenUsingAuthorizationCode,
+ getOAuth2AuthorizationCode,
+ getOAuth2TokenUsingClientCredentials,
+ getOAuth2TokenUsingPasswordCredentials,
+ refreshOauth2Token
+};
\ No newline at end of file
diff --git a/packages/bruno-electron/src/utils/proxy-util.js b/packages/bruno-electron/src/utils/proxy-util.js
index ef64d37ad..2a9ef26cb 100644
--- a/packages/bruno-electron/src/utils/proxy-util.js
+++ b/packages/bruno-electron/src/utils/proxy-util.js
@@ -1,6 +1,11 @@
const parseUrl = require('url').parse;
-const { isEmpty } = require('lodash');
+const https = require('https');
const { HttpsProxyAgent } = require('https-proxy-agent');
+const { interpolateString } = require('../ipc/network/interpolate-string');
+const { SocksProxyAgent } = require('socks-proxy-agent');
+const { HttpProxyAgent } = require('http-proxy-agent');
+const { preferencesUtil } = require('../store/preferences');
+const { isEmpty, get, isUndefined, isNull } = require('lodash');
const DEFAULT_PORTS = {
ftp: 21,
@@ -79,7 +84,307 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
+function createTimelineAgentClass(BaseAgentClass) {
+ return class extends BaseAgentClass {
+ constructor(options, timeline) {
+ // For proxy agents, the first argument is the proxy URI and the second is options
+ if (options?.proxy) {
+ const { proxy: proxyUri, ...agentOptions } = options;
+ // Ensure TLS options are properly set
+ const tlsOptions = {
+ ...agentOptions,
+ rejectUnauthorized: agentOptions.rejectUnauthorized ?? true,
+ };
+ super(proxyUri, tlsOptions);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+ this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
+ this.caProvided = !!tlsOptions.ca;
+
+ // Log TLS verification status
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
+ });
+
+ // Log the proxy details
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Using proxy: ${proxyUri}`,
+ });
+ } else {
+ // This is a regular HTTPS agent case
+ const tlsOptions = {
+ ...options,
+ rejectUnauthorized: options.rejectUnauthorized ?? true,
+ };
+ super(tlsOptions);
+ this.timeline = Array.isArray(timeline) ? timeline : [];
+ this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
+ this.caProvided = !!options.ca;
+
+ // Log TLS verification status
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`,
+ });
+ }
+ }
+
+
+ createConnection(options, callback) {
+ const { host, port } = options;
+
+ // Log ALPN protocols offered
+ if (this.alpnProtocols && this.alpnProtocols.length > 0) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `ALPN: offers ${this.alpnProtocols.join(', ')}`,
+ });
+ }
+
+ // Log CAfile and CApath (if possible)
+ if (this.caProvided) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `CA certificates provided`,
+ });
+ } else {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `Using system default CA certificates`,
+ });
+ }
+
+ // Log "Trying host:port..."
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Trying ${host}:${port}...`,
+ });
+
+ let socket;
+ try {
+ socket = super.createConnection(options, callback);
+ } catch (error) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: `Error creating connection: ${error.message}`,
+ });
+ error.timeline = this.timeline;
+ throw error;
+ }
+
+ // Attach event listeners to the socket
+ socket?.on('lookup', (err, address, family, host) => {
+ if (err) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: `DNS lookup error for ${host}: ${err.message}`,
+ });
+ } else {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `DNS lookup: ${host} -> ${address}`,
+ });
+ }
+ });
+
+ socket?.on('connect', () => {
+ const address = socket.remoteAddress || host;
+ const remotePort = socket.remotePort || port;
+
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'info',
+ message: `Connected to ${host} (${address}) port ${remotePort}`,
+ });
+ });
+
+ socket?.on('secureConnect', () => {
+ const protocol = socket.getProtocol() || 'SSL/TLS';
+ const cipher = socket.getCipher();
+ const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
+
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `SSL connection using ${protocol} / ${cipherSuite}`,
+ });
+
+ // ALPN protocol
+ const alpnProtocol = socket.alpnProtocol || 'None';
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `ALPN: server accepted ${alpnProtocol}`,
+ });
+
+ // Server certificate
+ const cert = socket.getPeerCertificate(true);
+ if (cert) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `Server certificate:`,
+ });
+ if (cert.subject) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`,
+ });
+ }
+ if (cert.valid_from) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: ` start date: ${cert.valid_from}`,
+ });
+ }
+ if (cert.valid_to) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: ` expire date: ${cert.valid_to}`,
+ });
+ }
+ if (cert.subjectaltname) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: ` subjectAltName: ${cert.subjectaltname}`,
+ });
+ }
+ if (cert.issuer) {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`,
+ });
+ }
+
+ // SSL certificate verify ok
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'tls',
+ message: `SSL certificate verify ok.`,
+ });
+ }
+ });
+
+ socket?.on('error', (err) => {
+ this.timeline.push({
+ timestamp: new Date(),
+ type: 'error',
+ message: `Socket error: ${err.message}`,
+ });
+ });
+
+ return socket;
+ }
+ };
+}
+
+function setupProxyAgents({
+ requestConfig,
+ proxyMode = 'off',
+ proxyConfig,
+ httpsAgentRequestFields,
+ interpolationOptions,
+ timeline,
+}) {
+ // Ensure TLS options are properly set
+ const tlsOptions = {
+ ...httpsAgentRequestFields,
+ // Enable all secure protocols by default
+ secureProtocol: undefined,
+ // Allow Node.js to choose the protocol
+ minVersion: 'TLSv1',
+ rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
+ };
+
+ if (proxyMode === 'on') {
+ const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
+ if (shouldProxy) {
+ const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
+ const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
+ const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
+ const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
+ const socksEnabled = proxyProtocol.includes('socks');
+
+ let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
+ let proxyUri;
+ if (proxyAuthEnabled) {
+ const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
+ const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
+ proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
+ } else {
+ proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
+ }
+
+ if (socksEnabled) {
+ const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent);
+ requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline);
+ requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline);
+ } else {
+ const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
+ requestConfig.httpAgent = new HttpProxyAgent(proxyUri); // For http, no need for timeline
+ requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
+ { proxy: proxyUri, ...tlsOptions },
+ timeline
+ );
+ }
+ } else {
+ // If proxy should not be used, set default HTTPS agent
+ const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
+ requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
+ }
+ } else if (proxyMode === 'system') {
+ const { http_proxy, https_proxy, no_proxy } = preferencesUtil.getSystemProxyEnvVariables();
+ const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
+ if (shouldUseSystemProxy) {
+ try {
+ if (http_proxy?.length) {
+ new URL(http_proxy);
+ requestConfig.httpAgent = new HttpProxyAgent(http_proxy);
+ }
+ } catch (error) {
+ throw new Error('Invalid system http_proxy');
+ }
+ try {
+ if (https_proxy?.length) {
+ new URL(https_proxy);
+ const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
+ requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
+ { proxy: https_proxy,...tlsOptions },
+ timeline
+ );
+ }
+ } catch (error) {
+ throw new Error('Invalid system https_proxy');
+ }
+ } else {
+ const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
+ requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
+ }
+ } else {
+ const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
+ requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
+ }
+}
+
+
module.exports = {
shouldUseProxy,
- PatchedHttpsProxyAgent
+ PatchedHttpsProxyAgent,
+ setupProxyAgents
};
diff --git a/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js b/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js
new file mode 100644
index 000000000..60add1b57
--- /dev/null
+++ b/packages/bruno-electron/src/utils/tests/filesystem/index.spec.js
@@ -0,0 +1,116 @@
+const path = require('path');
+const fs = require('fs/promises');
+const os = require('os');
+const { copyPath, removePath } = require('../../filesystem');
+const { initialCollectionStructure, finalCollectionStructure } = require('../fixtures/filesystem/copypath-removepath');
+
+describe('File System Operations', () => {
+ let tempDir;
+
+ beforeAll(async () => {
+ // Create a temporary directory for each test
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bruno-test-'));
+ await createFilesAndFolders(tempDir, initialCollectionStructure);
+ const result = await verifyFilesAndFolders(tempDir, initialCollectionStructure);
+ expect(result).toBe(true);
+ });
+
+ afterAll(async () => {
+ // clean up after each test
+ await fs.rm(tempDir, { recursive: true, force: true });
+ // confirm the temp directory is deleted
+ expect(await fs.access(tempDir).then(() => true).catch(() => false)).toBe(false);
+ });
+
+ describe('copyPath and removePath', () => {
+ it('should move files and folder items multiple times', async () => {
+
+ {
+ const sourcePath = path.join(tempDir, 'folder_1', 'file_2.bru');
+ const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
+ await copyPath(sourcePath, destDir);
+ await removePath(sourcePath);
+ }
+
+ {
+ const sourcePath = path.join(tempDir, 'folder_2');
+ const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
+ await copyPath(sourcePath, destDir);
+ await removePath(sourcePath);
+ }
+
+ {
+ const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'file_2_2.bru');
+ const destDir = path.join(tempDir, 'folder_1');
+ await copyPath(sourcePath, destDir);
+ await removePath(sourcePath);
+ }
+
+ {
+ const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'folder_2_1');
+ const destDir = path.join(tempDir);
+ await copyPath(sourcePath, destDir);
+ await removePath(sourcePath);
+ }
+
+ const result = await verifyFilesAndFolders(tempDir, finalCollectionStructure);
+ expect(result).toBe(true);
+ });
+
+
+ it('should throw an error move file/folder if the destination has the same filename', async () => {
+ {
+ const sourcePath = path.join(tempDir, 'folder_1', 'file_dup.bru');
+ const destDir = path.join(tempDir, 'folder_1');
+ await expect(copyPath(sourcePath, destDir)).rejects.toThrow();
+ }
+ });
+
+ });
+});
+
+
+// create folders and files recursively based on the defined json structure
+const createFilesAndFolders = async (dir, filesAndFolders) => {
+ for (const item of filesAndFolders) {
+ const itemPath = path.join(dir, item.name);
+ if (item.type === 'folder') {
+ await fs.mkdir(itemPath, { recursive: true });
+ await createFilesAndFolders(itemPath, item.files);
+ } else {
+ await fs.writeFile(itemPath, item.content);
+ }
+ }
+}
+
+// if a file/folder doesnt exist, return false
+// should only contain files and folders that are defined in the json structure
+const verifyFilesAndFolders = async (dir, filesAndFolders) => {
+ const verify = async (dir, filesAndFolders) => {
+ const files = await fs.readdir(dir);
+ if (files.length !== filesAndFolders.length) {
+ return false;
+ }
+ for (const file of files) {
+ const itemPath = path.join(dir, file);
+ const item = filesAndFolders.find(f => f.name === file);
+ if (!item) {
+ return false;
+ }
+ if (item.type === 'folder') {
+ return await verify(itemPath, item.files);
+ } else {
+ return await fs.readFile(itemPath, 'utf8').then(content => content === item.content);
+ }
+ }
+ return true;
+ }
+
+ try {
+ const verified = await verify(dir, filesAndFolders);
+ return verified;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js b/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js
new file mode 100644
index 000000000..ea08f8d25
--- /dev/null
+++ b/packages/bruno-electron/src/utils/tests/fixtures/filesystem/copypath-removepath.js
@@ -0,0 +1,155 @@
+const initialCollectionStructure = [
+ {
+ "name": "folder_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_1.bru",
+ "type": "file",
+ "content": "file_1_content"
+ },
+ {
+ "name": "file_2.bru",
+ "type": "file",
+ "content": "file_2_content"
+ },
+ {
+ "name": "folder_1_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_1_1.bru",
+ "type": "file",
+ "content": "file_1_1_content"
+ },
+ {
+ "name": "file_1_2.bru",
+ "type": "file",
+ "content": "file_1_2_content"
+ }
+ ]
+ },
+ {
+ "name": "file_1_3.bru",
+ "type": "file",
+ "content": "file_1_3_content"
+ },
+ {
+ "name": "file_dup.bru",
+ "type": "file",
+ "content": "file_dup_content"
+ }
+ ],
+ },
+ {
+ "name": "folder_2",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_2_1.bru",
+ "type": "file",
+ "content": "file_2_1_content"
+ },
+ {
+ "name": "file_2_2.bru",
+ "type": "file",
+ "content": "file_2_2_content"
+ },
+ {
+ "name": "folder_2_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_2_1_1.bru",
+ "type": "file",
+ "content": "file_2_1_1_content"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "file_dup.bru",
+ "type": "file",
+ "content": "file_dup_content"
+ }
+];
+
+const finalCollectionStructure = [
+ {
+ "name": "folder_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_1.bru",
+ "type": "file",
+ "content": "file_1_content"
+ },
+ {
+ "name": "folder_1_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_1_1.bru",
+ "type": "file",
+ "content": "file_1_1_content"
+ },
+ {
+ "name": "file_1_2.bru",
+ "type": "file",
+ "content": "file_1_2_content"
+ },
+ {
+ "name": "file_2.bru",
+ "type": "file",
+ "content": "file_2_content"
+ },
+ {
+ "name": "folder_2",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_2_1.bru",
+ "type": "file",
+ "content": "file_2_1_content"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "file_1_3.bru",
+ "type": "file",
+ "content": "file_1_3_content"
+ },
+ {
+ "name": "file_2_2.bru",
+ "type": "file",
+ "content": "file_2_2_content"
+ },
+ {
+ "name": "file_dup.bru",
+ "type": "file",
+ "content": "file_dup_content"
+ }
+ ],
+ },
+ {
+ "name": "folder_2_1",
+ "type": "folder",
+ "files": [
+ {
+ "name": "file_2_1_1.bru",
+ "type": "file",
+ "content": "file_2_1_1_content"
+ }
+ ]
+ },
+ {
+ "name": "file_dup.bru",
+ "type": "file",
+ "content": "file_dup_content"
+ }
+];
+
+module.exports = { initialCollectionStructure, finalCollectionStructure };
\ No newline at end of file
diff --git a/packages/bruno-electron/src/workers/index.js b/packages/bruno-electron/src/workers/index.js
new file mode 100644
index 000000000..d1d1a1b74
--- /dev/null
+++ b/packages/bruno-electron/src/workers/index.js
@@ -0,0 +1,68 @@
+const { Worker } = require('worker_threads');
+
+class WorkerQueue {
+ constructor() {
+ this.queue = [];
+ this.isProcessing = false;
+ this.workers = {};
+ }
+
+ async getWorkerForScriptPath(scriptPath) {
+ if (!this.workers) this.workers = {};
+ let worker = this.workers[scriptPath];
+ if (!worker || worker.threadId === -1) {
+ this.workers[scriptPath] = worker = new Worker(scriptPath);
+ }
+ return worker;
+ }
+
+ 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(async (resolve, reject) => {
+ let worker = await this.getWorkerForScriptPath(scriptPath);
+ worker.postMessage(data);
+ worker.on('message', (data) => {
+ if (data?.error) {
+ reject(new Error(data?.error));
+ }
+ resolve(data);
+ });
+ worker.on('error', (error) => {
+ reject(error);
+ });
+ worker.on('exit', (code) => {
+ reject(new Error(`stopped with ${code} exit code`));
+ });
+ });
+ }
+}
+
+module.exports = WorkerQueue;
diff --git a/packages/bruno-electron/tests/network/authorize-user.spec.js b/packages/bruno-electron/tests/network/authorize-user.spec.js
new file mode 100644
index 000000000..03c76cfb8
--- /dev/null
+++ b/packages/bruno-electron/tests/network/authorize-user.spec.js
@@ -0,0 +1,19 @@
+const { matchesCallbackUrl } = require('../../src/ipc/network/authorize-user-in-window');
+
+describe('matchesCallbackUrl', () => {
+ const testCases = [
+ { url: 'https://random-url/endpoint', expected: false },
+ { url: 'https://random-url/endpoint?code=abcd', expected: false },
+ { url: 'https://callback.url/endpoint?code=abcd', expected: true },
+ { url: 'https://callback.url/endpoint/?code=abcd', expected: true },
+ { url: 'https://callback.url/random-endpoint/?code=abcd', expected: false }
+ ];
+
+ it.each(testCases)('$url - should be $expected', ({ url, expected }) => {
+ let callBackUrl = 'https://callback.url/endpoint';
+
+ let actual = matchesCallbackUrl(new URL(url), new URL(callBackUrl));
+
+ expect(actual).toBe(expected);
+ });
+});
diff --git a/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js b/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js
new file mode 100644
index 000000000..8831ba48b
--- /dev/null
+++ b/packages/bruno-electron/tests/network/fetch-gql-schema-handler.spec.js
@@ -0,0 +1,371 @@
+const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request');
+const { fetchGqlSchemaHandler } = require('../../src/ipc/network');
+
+// Mock only the prepare-gql-introspection-request to avoid network calls
+jest.mock('../../src/ipc/network/prepare-gql-introspection-request', () => {
+ return jest.fn().mockImplementation((endpoint, vars, request, root) => {
+ return {
+ url: endpoint,
+ method: 'POST',
+ headers: request?.headers || {},
+ data: {
+ query: '{ __schema { types { name } } }'
+ }
+ };
+ });
+});
+
+describe('fetchGqlSchemaHandler - variable precedence', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should override global environment variables with environment variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: [
+ { name: 'SHARED_VAR', value: 'env-value', enabled: true }
+ ]
+ };
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [] // No request variables
+ }
+ };
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {},
+ globalEnvironmentVariables: {
+ SHARED_VAR: 'global-value'
+ },
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [] // No request variables
+ }
+ }
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [] // No collection variables
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'env-value'
+ }),
+ request,
+ collection.root
+ );
+ });
+
+ it('should override environment variables with folder-level variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: [
+ { name: 'SHARED_VAR', value: 'env-value', enabled: true }
+ ]
+ };
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [] // No request variables
+ }
+ };
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {},
+ globalEnvironmentVariables: {},
+ items: [
+ {
+ uid: 'test-folder',
+ type: 'folder',
+ root: {
+ request: {
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'folder-value', enabled: true }
+ ]
+ }
+ }
+ },
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [] // No request variables
+ }
+ }
+ }
+ ]
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [] // No collection variables
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'folder-value'
+ }),
+ request,
+ collection.root
+ );
+ });
+
+ it('should override folder-level variables with request variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: []
+ };
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'request-value', enabled: true }
+ ]
+ }
+ };
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {},
+ globalEnvironmentVariables: {},
+ items: [
+ {
+ uid: 'test-folder',
+ type: 'folder',
+ root: {
+ request: {
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'folder-value', enabled: true }
+ ]
+ }
+ }
+ },
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'request-value', enabled: true }
+ ]
+ }
+ }
+ }
+ ]
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [] // No collection variables
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'request-value'
+ }),
+ request,
+ collection.root
+ );
+ });
+
+ it('should override global environment variables with collection variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: []
+ };
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [] // No request variables
+ }
+ };
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {},
+ globalEnvironmentVariables: {
+ SHARED_VAR: 'global-value'
+ },
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [] // No request variables
+ }
+ }
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'collection-value', enabled: true }
+ ]
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'collection-value'
+ }),
+ request,
+ collection.root
+ );
+ });
+
+ it('should override collection variables with environment variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: [
+ { name: 'SHARED_VAR', value: 'env-value', enabled: true }
+ ]
+ };
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [] // No request variables
+ }
+ };
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {},
+ globalEnvironmentVariables: {},
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [] // No request variables
+ }
+ }
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'collection-value', enabled: true }
+ ]
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'env-value'
+ }),
+ request,
+ collection.root
+ );
+ });
+
+ it('should override request variables with runtime variables', async () => {
+ const endpoint = 'https://example.com/';
+ const environment = {
+ variables: []
+ };
+
+ const request = {
+ uid: 'test-request',
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'request-value', enabled: true }
+ ]
+ }
+ };
+
+ const collection = {
+ uid: 'test-collection',
+ pathname: '/test',
+ runtimeVariables: {
+ SHARED_VAR: 'runtime-value'
+ },
+ items: [
+ {
+ uid: 'test-request',
+ request: {
+ vars: {
+ req: [
+ { name: 'SHARED_VAR', value: 'request-value', enabled: true }
+ ]
+ }
+ }
+ }
+ ],
+ root: {
+ request: {
+ headers: [],
+ vars: {
+ req: [] // No collection variables
+ }
+ }
+ }
+ };
+
+ await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
+
+ expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({
+ SHARED_VAR: 'runtime-value'
+ }),
+ request,
+ collection.root
+ );
+ })
+});
+
+
diff --git a/packages/bruno-electron/tests/network/index.spec.js b/packages/bruno-electron/tests/network/index.spec.js
index 7c45c2538..02a9b9083 100644
--- a/packages/bruno-electron/tests/network/index.spec.js
+++ b/packages/bruno-electron/tests/network/index.spec.js
@@ -1,25 +1,15 @@
-// damn jest throws an error when no tests are found in a file
-// --passWithNoTests doesn't work
+const { configureRequest } = require('../../src/ipc/network/index');
-describe('dummy test', () => {
- it('should pass', () => {
- expect(true).toBe(true);
+describe('index: configureRequest', () => {
+ it("Should add 'http://' to the URL if no protocol is specified", async () => {
+ const request = { method: 'GET', url: 'test-domain', body: {} };
+ await configureRequest(null, request, null, null, null, null);
+ expect(request.url).toEqual('http://test-domain');
+ });
+
+ it("Should NOT add 'http://' to the URL if a protocol is specified", async () => {
+ const request = { method: 'GET', url: 'ftp://test-domain', body: {} };
+ await configureRequest(null, request, null, null, null, null);
+ expect(request.url).toEqual('ftp://test-domain');
});
});
-
-// todo: fix this failing test
-// const { configureRequest } = require('../../src/ipc/network/index');
-
-// describe('index: configureRequest', () => {
-// it("Should add 'http://' to the URL if no protocol is specified", async () => {
-// const request = { method: 'GET', url: 'test-domain', body: {} };
-// await configureRequest(null, request, null, null, null, null);
-// expect(request.url).toEqual('http://test-domain');
-// });
-
-// it("Should NOT add 'http://' to the URL if a protocol is specified", async () => {
-// const request = { method: 'GET', url: 'ftp://test-domain', body: {} };
-// await configureRequest(null, request, null, null, null, null);
-// expect(request.url).toEqual('ftp://test-domain');
-// });
-// });
diff --git a/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js b/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js
new file mode 100644
index 000000000..2eacde679
--- /dev/null
+++ b/packages/bruno-electron/tests/network/prepare-gql-introspection-request.spec.js
@@ -0,0 +1,66 @@
+const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request');
+
+describe('prepareGqlIntrospectionRequest', () => {
+ const createBasicSetup = () => ({
+ endpoint: 'https://example.com/',
+ request: {
+ headers: []
+ },
+ collectionRoot: {
+ request: {
+ headers: []
+ }
+ }
+ });
+
+ it('should handle environment variables in headers', () => {
+ const setup = createBasicSetup();
+ setup.request.headers = [
+ { name: 'Authorization', value: 'Bearer {{AUTH_TOKEN}}', enabled: true }
+ ];
+ const vars = {
+ AUTH_TOKEN: 'token-value'
+ };
+
+ const result = prepareGqlIntrospectionRequest(setup.endpoint, vars, setup.request, setup.collectionRoot);
+
+ expect(result.headers['Authorization']).toBe('Bearer token-value');
+ expect(result.method).toBe('POST');
+ expect(result.url).toBe(setup.endpoint);
+ });
+
+ it('should override collection headers with request headers', () => {
+ const setup = createBasicSetup();
+ setup.collectionRoot.request.headers = [
+ { name: 'X-Header', value: 'collection-value', enabled: true }
+ ];
+ setup.request.headers = [
+ { name: 'X-Header', value: 'request-value', enabled: true }
+ ];
+
+ const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
+
+ expect(result.headers['X-Header']).toBe('request-value');
+ });
+
+ it('should handle enabled and disabled headers', () => {
+ const setup = createBasicSetup();
+ setup.request.headers = [
+ { name: 'X-Enabled', value: 'enabled', enabled: true },
+ { name: 'X-Disabled', value: 'disabled', enabled: false }
+ ];
+
+ const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
+
+ expect(result.headers['X-Enabled']).toBe('enabled');
+ expect(result.headers['X-Disabled']).toBeUndefined();
+ });
+
+ it('should always include required GraphQL headers', () => {
+ const setup = createBasicSetup();
+ const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
+ expect(result.headers['Accept']).toBe('application/json');
+ expect(result.headers['Content-Type']).toBe('application/json');
+ });
+
+});
\ No newline at end of file
diff --git a/packages/bruno-electron/tests/network/prepare-request.spec.js b/packages/bruno-electron/tests/network/prepare-request.spec.js
new file mode 100644
index 000000000..67ed5ea87
--- /dev/null
+++ b/packages/bruno-electron/tests/network/prepare-request.spec.js
@@ -0,0 +1,62 @@
+const { describe, it, expect } = require('@jest/globals');
+
+const { prepareRequest } = require('../../src/ipc/network/prepare-request');
+const { buildFormUrlEncodedPayload } = require('../../src/utils/form-data');
+
+describe('prepare-request: prepareRequest', () => {
+ describe('Decomments request body', () => {
+ it('If request body is valid JSON', async () => {
+ const body = { mode: 'json', json: '{\n"test": "{{someVar}}" // comment\n}' };
+ const expected = `{
+\"test\": \"{{someVar}}\"
+}`;
+ const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
+ expect(result.data).toEqual(expected);
+ });
+
+ it('If request body is not valid JSON', async () => {
+ const body = { mode: 'json', json: '{\n"test": {{someVar}} // comment\n}' };
+ const expected = '{\n"test": {{someVar}} \n}';
+ const result = await prepareRequest({ request: { body }, collection: { pathname: '' } });
+ expect(result.data).toEqual(expected);
+ });
+
+ it('should handle single key-value pair', () => {
+ const requestObj = [{ name: 'item', value: 2 }];
+ const expected = { item: 2 };
+ const result = buildFormUrlEncodedPayload(requestObj);
+ expect(result).toEqual(expected);
+ });
+
+ it('should handle multiple key-value pairs with unique keys', () => {
+ const requestObj = [
+ { name: 'item1', value: 2 },
+ { name: 'item2', value: 3 }
+ ];
+ const expected = { item1: 2, item2: 3 };
+ const result = buildFormUrlEncodedPayload(requestObj);
+ expect(result).toEqual(expected);
+ });
+
+ it('should handle multiple key-value pairs with the same key', () => {
+ const requestObj = [
+ { name: 'item', value: 2 },
+ { name: 'item', value: 3 }
+ ];
+ const expected = { item: [2, 3] };
+ const result = buildFormUrlEncodedPayload(requestObj);
+ expect(result).toEqual(expected);
+ });
+
+ it('should handle mixed key-value pairs with unique and duplicate keys', () => {
+ const requestObj = [
+ { name: 'item1', value: 2 },
+ { name: 'item2', value: 3 },
+ { name: 'item1', value: 4 }
+ ];
+ const expected = { item1: [2, 4], item2: 3 };
+ const result = buildFormUrlEncodedPayload(requestObj);
+ expect(result).toEqual(expected);
+ });
+ });
+});
\ No newline at end of file
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-electron/tests/utils/encryption.spec.js b/packages/bruno-electron/tests/utils/encryption.spec.js
index b7c9abcdd..ae13e8ee2 100644
--- a/packages/bruno-electron/tests/utils/encryption.spec.js
+++ b/packages/bruno-electron/tests/utils/encryption.spec.js
@@ -12,16 +12,33 @@ describe('Encryption and Decryption Tests', () => {
expect(decrypted).toBe(plaintext);
});
+ it('should handle empty strings in encryptString', () => {
+ const result = encryptString('');
+ expect(result).toBe('');
+ });
+
+ it('should handle empty strings in decryptString', () => {
+ const result = decryptString('');
+ expect(result).toBe('');
+ });
+
it('encrypt should throw an error for invalid string', () => {
expect(() => encryptString(null)).toThrow('Encrypt failed: invalid string');
+ expect(() => encryptString(undefined)).toThrow('Encrypt failed: invalid string');
});
it('decrypt should throw an error for invalid string', () => {
expect(() => decryptString(null)).toThrow('Decrypt failed: unrecognized string format');
- expect(() => decryptString('')).toThrow('Decrypt failed: unrecognized string format');
expect(() => decryptString('garbage')).toThrow('Decrypt failed: unrecognized string format');
});
+ it.skip('string encrypted using createCipher (< node 20) should be decrypted properly', () => {
+ const encryptedString = '$01:2738e0e6a38bcde5fd80141ceadc9b67bc7b1fca7e398c552c1ca2bace28eb57';
+ const decryptedValue = decryptString(encryptedString);
+
+ expect(decryptedValue).toBe('bruno is awesome');
+ });
+
it('decrypt should throw an error for invalid algorithm', () => {
const invalidAlgo = '$99:abcdefg';
diff --git a/packages/bruno-graphql-docs/package.json b/packages/bruno-graphql-docs/package.json
index 393a3d792..548f212c9 100644
--- a/packages/bruno-graphql-docs/package.json
+++ b/packages/bruno-graphql-docs/package.json
@@ -9,7 +9,8 @@
"package.json"
],
"scripts": {
- "build": "rollup -c"
+ "build": "rollup -c",
+ "watch": "rollup -c -w"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
@@ -19,10 +20,10 @@
"@types/react": "^18.0.25",
"graphql": "^16.6.0",
"markdown-it": "^13.0.1",
- "postcss": "^8.4.18",
- "react": "18.2.0",
+ "postcss": "8.4.47",
+ "react": "19.0.0",
"react-dom": "18.2.0",
- "rollup": "3.2.5",
+ "rollup":"3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
@@ -34,6 +35,6 @@
"markdown-it": "^13.0.1"
},
"overrides": {
- "rollup": "3.2.5"
+ "rollup":"3.29.5"
}
}
diff --git a/packages/bruno-js/.gitignore b/packages/bruno-js/.gitignore
new file mode 100644
index 000000000..22322eb1b
--- /dev/null
+++ b/packages/bruno-js/.gitignore
@@ -0,0 +1 @@
+src/sandbox/bundle-browser-rollup.js
\ No newline at end of file
diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json
index 8682608d2..c040244a3 100644
--- a/packages/bruno-js/package.json
+++ b/packages/bruno-js/package.json
@@ -8,28 +8,43 @@
"package.json"
],
"peerDependencies": {
- "@n8n/vm2": "^3.9.23"
+ "@usebruno/vm2": "^3.9.13"
},
"scripts": {
- "test": "jest --testPathIgnorePatterns test.js"
+ "test": "node --experimental-vm-modules $(npx which jest) --testPathIgnorePatterns test.js",
+ "sandbox:bundle-libraries": "node ./src/sandbox/bundle-libraries.js"
},
"dependencies": {
+ "@usebruno/common": "0.1.0",
+ "@usebruno/crypto-js": "^3.1.9",
"@usebruno/query": "0.1.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
- "axios": "^1.5.1",
+ "axios": "^1.8.3",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",
+ "cheerio": "^1.0.0",
"crypto-js": "^4.1.1",
- "handlebars": "^4.7.8",
"json-query": "^2.2.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
- "nanoid": "3.3.4",
- "node-fetch": "2.*",
+ "nanoid": "3.3.8",
+ "node-fetch": "^2.7.0",
"node-vault": "^0.10.2",
- "uuid": "^9.0.0"
+ "path": "^0.12.7",
+ "quickjs-emscripten": "^0.29.2",
+ "tv4": "^1.3.0",
+ "uuid": "^9.0.0",
+ "xml2js": "^0.6.2"
+ },
+ "devDependencies": {
+ "@rollup/plugin-commonjs": "^23.0.2",
+ "@rollup/plugin-node-resolve": "^15.0.1",
+ "rollup": "3.29.5",
+ "rollup-plugin-terser": "^7.0.2",
+ "stream": "^0.0.2",
+ "util": "^0.12.5"
}
}
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 964d7b609..d38d28983 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -1,30 +1,55 @@
-const Handlebars = require('handlebars');
const { cloneDeep } = require('lodash');
+const { interpolate: _interpolate } = require('@usebruno/common');
const variableNameRegex = /^[\w-.]*$/;
class Bru {
- constructor(envVariables, collectionVariables, processEnvVars, collectionPath) {
- this.envVariables = envVariables;
- this.collectionVariables = collectionVariables;
+ constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName) {
+ this.envVariables = envVariables || {};
+ this.runtimeVariables = runtimeVariables || {};
this.processEnvVars = cloneDeep(processEnvVars || {});
+ this.collectionVariables = collectionVariables || {};
+ this.folderVariables = folderVariables || {};
+ this.requestVariables = requestVariables || {};
+ this.globalEnvironmentVariables = globalEnvironmentVariables || {};
+ this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
+ this.collectionName = collectionName;
+ this.runner = {
+ skipRequest: () => {
+ this.skipRequest = true;
+ },
+ stopExecution: () => {
+ this.stopExecution = true;
+ },
+ setNextRequest: (nextRequest) => {
+ this.nextRequest = nextRequest;
+ }
+ };
}
- _interpolateEnvVar = (str) => {
- if (!str || !str.length || typeof str !== 'string') {
- return str;
- }
+ interpolate = (strOrObj) => {
+ if (!strOrObj) return strOrObj;
+ const isObj = typeof strOrObj === 'object';
+ const strToInterpolate = isObj ? JSON.stringify(strOrObj) : strOrObj;
- const template = Handlebars.compile(str, { noEscape: true });
-
- return template({
+ const combinedVars = {
+ ...this.globalEnvironmentVariables,
+ ...this.collectionVariables,
+ ...this.envVariables,
+ ...this.folderVariables,
+ ...this.requestVariables,
+ ...this.oauth2CredentialVariables,
+ ...this.runtimeVariables,
process: {
env: {
...this.processEnvVars
}
}
- });
+ };
+
+ const interpolatedStr = _interpolate(strToInterpolate, combinedVars);
+ return isObj ? JSON.parse(interpolatedStr) : interpolatedStr;
};
cwd() {
@@ -39,8 +64,12 @@ class Bru {
return this.processEnvVars[key];
}
+ hasEnvVar(key) {
+ return Object.hasOwn(this.envVariables, key);
+ }
+
getEnvVar(key) {
- return this._interpolateEnvVar(this.envVariables[key]);
+ return this.interpolate(this.envVariables[key]);
}
setEnvVar(key, value) {
@@ -51,6 +80,30 @@ class Bru {
this.envVariables[key] = value;
}
+ deleteEnvVar(key) {
+ delete this.envVariables[key];
+ }
+
+ getGlobalEnvVar(key) {
+ return this.interpolate(this.globalEnvironmentVariables[key]);
+ }
+
+ setGlobalEnvVar(key, value) {
+ if (!key) {
+ throw new Error('Creating a env variable without specifying a name is not allowed.');
+ }
+
+ this.globalEnvironmentVariables[key] = value;
+ }
+
+ getOauth2CredentialVar(key) {
+ return this.interpolate(this.oauth2CredentialVariables[key]);
+ }
+
+ hasVar(key) {
+ return Object.hasOwn(this.runtimeVariables, key);
+ }
+
setVar(key, value) {
if (!key) {
throw new Error('Creating a variable without specifying a name is not allowed.');
@@ -63,7 +116,7 @@ class Bru {
);
}
- this.collectionVariables[key] = value;
+ this.runtimeVariables[key] = value;
}
getVar(key) {
@@ -74,12 +127,44 @@ class Bru {
);
}
- return this.collectionVariables[key];
+ return this.interpolate(this.runtimeVariables[key]);
+ }
+
+ deleteVar(key) {
+ delete this.runtimeVariables[key];
+ }
+
+ deleteAllVars() {
+ for (let key in this.runtimeVariables) {
+ if (this.runtimeVariables.hasOwnProperty(key)) {
+ delete this.runtimeVariables[key];
+ }
+ }
+ }
+
+ getCollectionVar(key) {
+ return this.interpolate(this.collectionVariables[key]);
+ }
+
+ getFolderVar(key) {
+ return this.interpolate(this.folderVariables[key]);
+ }
+
+ getRequestVar(key) {
+ return this.interpolate(this.requestVariables[key]);
}
setNextRequest(nextRequest) {
this.nextRequest = nextRequest;
}
+
+ sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+
+ getCollectionName() {
+ return this.collectionName;
+ }
}
module.exports = Bru;
diff --git a/packages/bruno-js/src/bruno-request.js b/packages/bruno-js/src/bruno-request.js
index 909adf92a..3ee2127ca 100644
--- a/packages/bruno-js/src/bruno-request.js
+++ b/packages/bruno-js/src/bruno-request.js
@@ -1,11 +1,34 @@
class BrunoRequest {
+ /**
+ * The following properties are available as shorthand:
+ * - req.url
+ * - req.method
+ * - req.headers
+ * - req.timeout
+ * - req.body
+ *
+ * Above shorthands are useful for accessing the request properties directly in the scripts
+ * It must be noted that the user cannot set these properties directly.
+ * They should use the respective setter methods to set these properties.
+ */
constructor(req) {
this.req = req;
this.url = req.url;
this.method = req.method;
this.headers = req.headers;
- this.body = req.data;
this.timeout = req.timeout;
+ this.name = req.name;
+ /**
+ * We automatically parse the JSON body if the content type is JSON
+ * This is to make it easier for the user to access the body directly
+ *
+ * It must be noted that the request data is always a string and is what gets sent over the network
+ * If the user wants to access the raw data, they can use getBody({raw: true}) method
+ */
+ const isJson = this.hasJSONContentType(this.req.headers);
+ if (isJson) {
+ this.body = this.__safeParseJSON(req.data);
+ }
}
getUrl() {
@@ -13,13 +36,13 @@ class BrunoRequest {
}
setUrl(url) {
+ this.url = url;
this.req.url = url;
}
getMethod() {
return this.req.method;
}
-
getAuthMode() {
if (this.req?.oauth2) {
return 'oauth2';
@@ -31,12 +54,15 @@ class BrunoRequest {
return 'awsv4';
} else if (this.req?.digestConfig) {
return 'digest';
+ } else if (this.headers?.['X-WSSE'] || this.req?.auth?.username) {
+ return 'wsse';
} else {
return 'none';
}
}
setMethod(method) {
+ this.method = method;
this.req.method = method;
}
@@ -45,6 +71,7 @@ class BrunoRequest {
}
setHeaders(headers) {
+ this.headers = headers;
this.req.headers = headers;
}
@@ -53,15 +80,60 @@ class BrunoRequest {
}
setHeader(name, value) {
+ this.headers[name] = value;
this.req.headers[name] = value;
}
- getBody() {
+ hasJSONContentType(headers) {
+ const contentType = headers?.['Content-Type'] || headers?.['content-type'] || '';
+ return contentType.includes('json');
+ }
+
+ /**
+ * Get the body of the request
+ *
+ * We automatically parse and return the JSON body if the content type is JSON
+ * If the user wants the raw body, they can pass the raw option as true
+ */
+ getBody(options = {}) {
+ if (options.raw) {
+ return this.req.data;
+ }
+
+ const isJson = this.hasJSONContentType(this.req.headers);
+ if (isJson) {
+ return this.__safeParseJSON(this.req.data);
+ }
+
return this.req.data;
}
- setBody(data) {
+ /**
+ * If the content type is JSON and if the data is an object
+ * - We set the body property as the object itself
+ * - We set the request data as the stringified JSON as it is what gets sent over the network
+ * Otherwise
+ * - We set the request data as the data itself
+ * - We set the body property as the data itself
+ *
+ * If the user wants to override this behavior, they can pass the raw option as true
+ */
+ setBody(data, options = {}) {
+ if (options.raw) {
+ this.req.data = data;
+ this.body = data;
+ return;
+ }
+
+ const isJson = this.hasJSONContentType(this.req.headers);
+ if (isJson && this.__isObject(data)) {
+ this.body = data;
+ this.req.data = this.__safeStringifyJSON(data);
+ return;
+ }
+
this.req.data = data;
+ this.body = data;
}
setMaxRedirects(maxRedirects) {
@@ -73,8 +145,42 @@ class BrunoRequest {
}
setTimeout(timeout) {
+ this.timeout = timeout;
this.req.timeout = timeout;
}
+
+ __safeParseJSON(str) {
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return str;
+ }
+ }
+
+ __safeStringifyJSON(obj) {
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return obj;
+ }
+ }
+
+ __isObject(obj) {
+ return obj !== null && typeof obj === 'object';
+ }
+
+
+ disableParsingResponseJson() {
+ this.req.__brunoDisableParsingResponseJson = true;
+ }
+
+ getExecutionMode() {
+ return this.req.__bruno__executionMode;
+ }
+
+ getName() {
+ return this.req.name;
+ }
}
module.exports = BrunoRequest;
diff --git a/packages/bruno-js/src/bruno-response.js b/packages/bruno-js/src/bruno-response.js
index 57e11a646..40b087f25 100644
--- a/packages/bruno-js/src/bruno-response.js
+++ b/packages/bruno-js/src/bruno-response.js
@@ -1,3 +1,6 @@
+const { get } = require('@usebruno/query');
+const _ = require('lodash');
+
class BrunoResponse {
constructor(res) {
this.res = res;
@@ -6,12 +9,23 @@ 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() {
return this.res ? this.res.status : null;
}
+ getStatusText() {
+ return this.res ? this.res.statusText : null;
+ }
+
getHeader(name) {
return this.res && this.res.headers ? this.res.headers[name] : null;
}
@@ -27,6 +41,16 @@ class BrunoResponse {
getResponseTime() {
return this.res ? this.res.responseTime : null;
}
+
+ setBody(data) {
+ if (!this.res) {
+ return;
+ }
+
+ const clonedData = _.cloneDeep(data);
+ this.res.data = clonedData;
+ this.body = clonedData;
+ }
}
module.exports = BrunoResponse;
diff --git a/packages/bruno-js/src/interpolate-string.js b/packages/bruno-js/src/interpolate-string.js
index 22910c1a3..f75daf57c 100644
--- a/packages/bruno-js/src/interpolate-string.js
+++ b/packages/bruno-js/src/interpolate-string.js
@@ -1,45 +1,20 @@
-const Handlebars = require('handlebars');
-const { forOwn, cloneDeep } = require('lodash');
+const { interpolate } = require('@usebruno/common');
-const interpolateEnvVars = (str, processEnvVars) => {
+const interpolateString = (
+ str,
+ { envVariables = {}, runtimeVariables = {}, processEnvVars = {}, collectionVariables = {}, folderVariables = {}, requestVariables = {}, globalEnvironmentVariables = {} }
+) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
- const template = Handlebars.compile(str, { noEscape: true });
-
- return template({
- process: {
- env: {
- ...processEnvVars
- }
- }
- });
-};
-
-const interpolateString = (str, { envVariables, collectionVariables, processEnvVars }) => {
- if (!str || !str.length || typeof str !== 'string') {
- return str;
- }
-
- processEnvVars = processEnvVars || {};
- collectionVariables = collectionVariables || {};
-
- // we clone envVariables because we don't want to modify the original object
- envVariables = envVariables ? cloneDeep(envVariables) : {};
-
- // envVariables can inturn have values as {{process.env.VAR_NAME}}
- // so we need to interpolate envVariables first with processEnvVars
- forOwn(envVariables, (value, key) => {
- envVariables[key] = interpolateEnvVars(value, processEnvVars);
- });
-
- const template = Handlebars.compile(str, { noEscape: true });
-
- // collectionVariables take precedence over envVariables
const combinedVars = {
- ...envVariables,
+ ...globalEnvironmentVariables,
...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...runtimeVariables,
process: {
env: {
...processEnvVars
@@ -47,7 +22,7 @@ const interpolateString = (str, { envVariables, collectionVariables, processEnvV
}
};
- return template(combinedVars);
+ return interpolate(str, combinedVars);
};
module.exports = {
diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js
index 36fd7c9f2..4e997d314 100644
--- a/packages/bruno-js/src/runtime/assert-runtime.js
+++ b/packages/bruno-js/src/runtime/assert-runtime.js
@@ -5,6 +5,7 @@ const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
const { interpolateString } = require('../interpolate-string');
+const { executeQuickJsVm } = require('../sandbox/quickjs');
const { expect } = chai;
chai.use(require('chai-string'));
@@ -57,6 +58,7 @@ chai.use(function (chai, utils) {
* endsWith : ends with
* between : between
* isEmpty : is empty
+ * isNotEmpty : is not empty
* isNull : is null
* isUndefined : is undefined
* isDefined : is defined
@@ -66,6 +68,7 @@ chai.use(function (chai, utils) {
* isNumber : is number
* isString : is string
* isBoolean : is boolean
+ * isArray : is array
*/
const parseAssertionOperator = (str = '') => {
if (!str || typeof str !== 'string' || !str.length) {
@@ -93,6 +96,7 @@ const parseAssertionOperator = (str = '') => {
'endsWith',
'between',
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -101,11 +105,13 @@ const parseAssertionOperator = (str = '') => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
const unaryOperators = [
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -114,7 +120,8 @@ const parseAssertionOperator = (str = '') => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
const [operator, ...rest] = str.trim().split(' ');
@@ -143,6 +150,7 @@ const parseAssertionOperator = (str = '') => {
const isUnaryOperator = (operator) => {
const unaryOperators = [
'isEmpty',
+ 'isNotEmpty',
'isNull',
'isUndefined',
'isDefined',
@@ -151,19 +159,48 @@ const isUnaryOperator = (operator) => {
'isJson',
'isNumber',
'isString',
- 'isBoolean'
+ 'isBoolean',
+ 'isArray'
];
return unaryOperators.includes(operator);
};
-const evaluateRhsOperand = (rhsOperand, operator, context) => {
+const evaluateJsTemplateLiteralBasedOnRuntime = (literal, context, runtime) => {
+ if (runtime === 'quickjs') {
+ return executeQuickJsVm({
+ script: literal,
+ context,
+ scriptType: 'template-literal'
+ });
+ }
+
+ return evaluateJsTemplateLiteral(literal, context);
+};
+
+const evaluateJsExpressionBasedOnRuntime = (expr, context, runtime) => {
+ if (runtime === 'quickjs') {
+ return executeQuickJsVm({
+ script: expr,
+ context,
+ scriptType: 'expression'
+ });
+ }
+
+ return evaluateJsExpression(expr, context);
+};
+
+const evaluateRhsOperand = (rhsOperand, operator, context, runtime) => {
if (isUnaryOperator(operator)) {
return;
}
const interpolationContext = {
+ globalEnvironmentVariables: context.bru.globalEnvironmentVariables,
collectionVariables: context.bru.collectionVariables,
+ folderVariables: context.bru.folderVariables,
+ requestVariables: context.bru.requestVariables,
+ runtimeVariables: context.bru.runtimeVariables,
envVariables: context.bru.envVariables,
processEnvVars: context.bru.processEnvVars
};
@@ -176,13 +213,17 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return rhsOperand
.split(',')
- .map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
+ .map((v) =>
+ evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(v.trim(), interpolationContext), context, runtime)
+ );
}
if (operator === 'between') {
const [lhs, rhs] = rhsOperand
.split(',')
- .map((v) => evaluateJsTemplateLiteral(interpolateString(v.trim(), interpolationContext), context));
+ .map((v) =>
+ evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(v.trim(), interpolationContext), context, runtime)
+ );
return [lhs, rhs];
}
@@ -195,17 +236,35 @@ const evaluateRhsOperand = (rhsOperand, operator, context) => {
return interpolateString(rhsOperand, interpolationContext);
}
- return evaluateJsTemplateLiteral(interpolateString(rhsOperand, interpolationContext), context);
+ return evaluateJsTemplateLiteralBasedOnRuntime(interpolateString(rhsOperand, interpolationContext), context, runtime);
};
class AssertRuntime {
- runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath) {
+ constructor(props) {
+ this.runtime = props?.runtime || 'vm2';
+ }
+
+ runAssertions(assertions, request, response, envVariables, runtimeVariables, processEnvVars) {
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
const enabledAssertions = _.filter(assertions, (a) => a.enabled);
if (!enabledAssertions.length) {
return [];
}
- const bru = new Bru(envVariables, collectionVariables);
+ const bru = new Bru(
+ envVariables,
+ runtimeVariables,
+ processEnvVars,
+ undefined,
+ collectionVariables,
+ folderVariables,
+ requestVariables,
+ globalEnvironmentVariables
+ );
const req = new BrunoRequest(request);
const res = createResponseParser(response);
@@ -216,8 +275,14 @@ class AssertRuntime {
};
const context = {
- ...envVariables,
+ ...globalEnvironmentVariables,
...collectionVariables,
+ ...envVariables,
+ ...folderVariables,
+ ...requestVariables,
+ ...oauth2CredentialVariables,
+ ...runtimeVariables,
+ ...processEnvVars,
...bruContext
};
@@ -230,8 +295,8 @@ class AssertRuntime {
const { operator, value: rhsOperand } = parseAssertionOperator(rhsExpr);
try {
- const lhs = evaluateJsExpression(lhsExpr, context);
- const rhs = evaluateRhsOperand(rhsOperand, operator, context);
+ const lhs = evaluateJsExpressionBasedOnRuntime(lhsExpr, context, this.runtime);
+ const rhs = evaluateRhsOperand(rhsOperand, operator, context, this.runtime);
switch (operator) {
case 'eq':
@@ -286,6 +351,9 @@ class AssertRuntime {
case 'isEmpty':
expect(lhs).to.be.empty;
break;
+ case 'isNotEmpty':
+ expect(lhs).to.not.be.empty;
+ break;
case 'isNull':
expect(lhs).to.be.null;
break;
@@ -313,6 +381,9 @@ class AssertRuntime {
case 'isBoolean':
expect(lhs).to.be.a('boolean');
break;
+ case 'isArray':
+ expect(lhs).to.be.a('array');
+ break;
default:
expect(lhs).to.equal(rhs);
break;
@@ -339,6 +410,8 @@ class AssertRuntime {
}
}
+ request.assertionResults = assertionResults;
+
return assertionResults;
}
}
diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js
index e1b7270bf..a18c94917 100644
--- a/packages/bruno-js/src/runtime/script-runtime.js
+++ b/packages/bruno-js/src/runtime/script-runtime.js
@@ -1,4 +1,4 @@
-const { NodeVM } = require('vm2');
+const { NodeVM } = require('@usebruno/vm2');
const path = require('path');
const http = require('http');
const https = require('https');
@@ -12,7 +12,10 @@ const { get } = require('lodash');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
+const Test = require('../test');
+const TestResults = require('../test-results');
const { cleanJson } = require('../utils');
+const { createBruTestResultMethods } = require('../utils/results');
// Inbuilt Library Support
const ajv = require('ajv');
@@ -28,9 +31,15 @@ const fetch = require('node-fetch');
const chai = require('chai');
const CryptoJS = require('crypto-js');
const NodeVault = require('node-vault');
+const xml2js = require('xml2js');
+const cheerio = require('cheerio');
+const tv4 = require('tv4');
+const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
class ScriptRuntime {
- constructor() {}
+ constructor(props) {
+ this.runtime = props?.runtime || 'vm2';
+ }
// This approach is getting out of hand
// Need to refactor this to use a single arg (object) instead of 7
@@ -38,13 +47,21 @@ class ScriptRuntime {
script,
request,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
+ const assertionResults = request?.assertionResults || [];
+ const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
@@ -65,9 +82,16 @@ class ScriptRuntime {
}
}
+ // extend bru with result getter methods
+ const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
+
const context = {
bru,
- req
+ req,
+ test,
+ expect: chai.expect,
+ assert: chai.assert,
+ __brunoTestResults: __brunoTestResults
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -85,10 +109,35 @@ class ScriptRuntime {
};
}
+ if (runRequestByItemPathname) {
+ context.bru.runRequest = runRequestByItemPathname;
+ }
+
+ if (this.runtime === 'quickjs') {
+ await executeQuickJsVmAsync({
+ script: script,
+ context: context,
+ collectionPath
+ });
+
+ return {
+ request,
+ envVariables: cleanJson(envVariables),
+ runtimeVariables: cleanJson(runtimeVariables),
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ results: cleanJson(__brunoTestResults.getResults()),
+ nextRequestName: bru.nextRequest,
+ skipRequest: bru.skipRequest,
+ stopExecution: bru.stopExecution
+ };
+ }
+
+ // default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
+ builtin: [ "*" ],
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
@@ -114,6 +163,9 @@ class ScriptRuntime {
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
+ xml2js: xml2js,
+ cheerio,
+ tv4,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
@@ -122,11 +174,16 @@ class ScriptRuntime {
});
const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
await asyncVM();
+
return {
request,
envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- nextRequestName: bru.nextRequest
+ runtimeVariables: cleanJson(runtimeVariables),
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ results: cleanJson(__brunoTestResults.getResults()),
+ nextRequestName: bru.nextRequest,
+ skipRequest: bru.skipRequest,
+ stopExecution: bru.stopExecution
};
}
@@ -135,17 +192,30 @@ class ScriptRuntime {
request,
response,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
+ const assertionResults = request?.assertionResults || [];
+ const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
+ const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
+ const additionalContextRootsAbsolute = lodash
+ .chain(additionalContextRoots)
+ .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
+ .value();
const whitelistedModules = {};
@@ -158,10 +228,17 @@ class ScriptRuntime {
}
}
+ // extend bru with result getter methods
+ const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
+
const context = {
bru,
req,
- res
+ res,
+ test,
+ expect: chai.expect,
+ assert: chai.assert,
+ __brunoTestResults: __brunoTestResults
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -174,16 +251,42 @@ class ScriptRuntime {
log: customLogger('log'),
info: customLogger('info'),
warn: customLogger('warn'),
- error: customLogger('error')
+ error: customLogger('error'),
+ debug: customLogger('debug')
};
}
+ if (runRequestByItemPathname) {
+ context.bru.runRequest = runRequestByItemPathname;
+ }
+
+ if (this.runtime === 'quickjs') {
+ await executeQuickJsVmAsync({
+ script: script,
+ context: context,
+ collectionPath
+ });
+
+ return {
+ response,
+ envVariables: cleanJson(envVariables),
+ runtimeVariables: cleanJson(runtimeVariables),
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ results: cleanJson(__brunoTestResults.getResults()),
+ nextRequestName: bru.nextRequest,
+ skipRequest: bru.skipRequest,
+ stopExecution: bru.stopExecution
+ };
+ }
+
+ // default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
+ builtin: [ "*" ],
external: true,
- root: [collectionPath],
+ root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,
@@ -206,6 +309,9 @@ class ScriptRuntime {
axios,
'node-fetch': fetch,
'crypto-js': CryptoJS,
+ 'xml2js': xml2js,
+ cheerio,
+ tv4,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
@@ -219,8 +325,12 @@ class ScriptRuntime {
return {
response,
envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- nextRequestName: bru.nextRequest
+ runtimeVariables: cleanJson(runtimeVariables),
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ results: cleanJson(__brunoTestResults.getResults()),
+ nextRequestName: bru.nextRequest,
+ skipRequest: bru.skipRequest,
+ stopExecution: bru.stopExecution
};
}
}
diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js
index e67bb6bbc..bed0589ca 100644
--- a/packages/bruno-js/src/runtime/test-runtime.js
+++ b/packages/bruno-js/src/runtime/test-runtime.js
@@ -1,4 +1,4 @@
-const { NodeVM } = require('vm2');
+const { NodeVM } = require('@usebruno/vm2');
const chai = require('chai');
const path = require('path');
const http = require('http');
@@ -16,6 +16,7 @@ const BrunoResponse = require('../bruno-response');
const Test = require('../test');
const TestResults = require('../test-results');
const { cleanJson } = require('../utils');
+const { createBruTestResultMethods } = require('../utils/results');
// Inbuilt Library Support
const ajv = require('ajv');
@@ -30,22 +31,35 @@ const axios = require('axios');
const fetch = require('node-fetch');
const CryptoJS = require('crypto-js');
const NodeVault = require('node-vault');
+const xml2js = require('xml2js');
+const cheerio = require('cheerio');
+const tv4 = require('tv4');
+const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
class TestRuntime {
- constructor() {}
+ constructor(props) {
+ this.runtime = props?.runtime || 'vm2';
+ }
async runTests(
testsFile,
request,
response,
envVariables,
- collectionVariables,
+ runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName
) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
+ const assertionResults = request?.assertionResults || [];
+ const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@@ -67,15 +81,17 @@ class TestRuntime {
}
}
- const __brunoTestResults = new TestResults();
- const test = Test(__brunoTestResults, chai);
+ // extend bru with result getter methods
+ const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
if (!testsFile || !testsFile.length) {
return {
request,
envVariables,
- collectionVariables,
- results: __brunoTestResults.getResults()
+ runtimeVariables,
+ globalEnvironmentVariables,
+ results: __brunoTestResults.getResults(),
+ nextRequestName: bru.nextRequest
};
}
@@ -99,54 +115,71 @@ class TestRuntime {
log: customLogger('log'),
info: customLogger('info'),
warn: customLogger('warn'),
+ debug: customLogger('debug'),
error: customLogger('error')
};
}
- const vm = new NodeVM({
- sandbox: context,
- require: {
- context: 'sandbox',
- external: true,
- root: [collectionPath, ...additionalContextRootsAbsolute],
- mock: {
- // node libs
- path,
- stream,
- util,
- url,
- http,
- https,
- punycode,
- zlib,
- // 3rd party libs
- ajv,
- 'ajv-formats': addFormats,
- btoa,
- atob,
- lodash,
- moment,
- uuid,
- nanoid,
- axios,
- chai,
- 'node-fetch': fetch,
- 'crypto-js': CryptoJS,
- ...whitelistedModules,
- fs: allowScriptFilesystemAccess ? fs : undefined,
- 'node-vault': NodeVault
- }
- }
- });
+ if(runRequestByItemPathname) {
+ context.bru.runRequest = runRequestByItemPathname;
+ }
- const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
- await asyncVM();
+ if (this.runtime === 'quickjs') {
+ await executeQuickJsVmAsync({
+ script: testsFile,
+ context: context
+ });
+ } else {
+ // default runtime is vm2
+ const vm = new NodeVM({
+ sandbox: context,
+ require: {
+ context: 'sandbox',
+ external: true,
+ root: [collectionPath, ...additionalContextRootsAbsolute],
+ mock: {
+ // node libs
+ path,
+ stream,
+ util,
+ url,
+ http,
+ https,
+ punycode,
+ zlib,
+ // 3rd party libs
+ ajv,
+ 'ajv-formats': addFormats,
+ btoa,
+ atob,
+ lodash,
+ moment,
+ uuid,
+ nanoid,
+ axios,
+ chai,
+ 'node-fetch': fetch,
+ 'crypto-js': CryptoJS,
+ 'xml2js': xml2js,
+ cheerio,
+ tv4,
+ ...whitelistedModules,
+ fs: allowScriptFilesystemAccess ? fs : undefined,
+ 'node-vault': NodeVault
+ }
+ }
+ });
+ const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
+ await asyncVM();
+ }
return {
request,
envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- results: cleanJson(__brunoTestResults.getResults())
+ runtimeVariables: cleanJson(runtimeVariables),
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ results: cleanJson(__brunoTestResults.getResults()),
+ nextRequestName: bru.nextRequest
};
}
}
diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js
index 0185ebddc..fba98e0fe 100644
--- a/packages/bruno-js/src/runtime/vars-runtime.js
+++ b/packages/bruno-js/src/runtime/vars-runtime.js
@@ -1,46 +1,41 @@
const _ = require('lodash');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
-const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
+const { evaluateJsExpression, createResponseParser } = require('../utils');
+const { cleanJson } = require('../utils');
-class VarsRuntime {
- runPreRequestVars(vars, request, envVariables, collectionVariables, collectionPath, processEnvVars) {
- const enabledVars = _.filter(vars, (v) => v.enabled);
- if (!enabledVars.length) {
- return;
- }
+const { executeQuickJsVm } = require('../sandbox/quickjs');
- const bru = new Bru(envVariables, collectionVariables, processEnvVars);
- const req = new BrunoRequest(request);
-
- const bruContext = {
- bru,
- req
- };
-
- const context = {
- ...envVariables,
- ...collectionVariables,
- ...bruContext
- };
-
- _.each(enabledVars, (v) => {
- const value = evaluateJsTemplateLiteral(v.value, context);
- bru.setVar(v.name, value);
+const evaluateJsExpressionBasedOnRuntime = (expr, context, runtime, mode) => {
+ if (runtime === 'quickjs') {
+ return executeQuickJsVm({
+ script: expr,
+ context,
+ scriptType: 'expression'
});
-
- return {
- collectionVariables
- };
}
- runPostResponseVars(vars, request, response, envVariables, collectionVariables, collectionPath, processEnvVars) {
+ return evaluateJsExpression(expr, context);
+};
+
+class VarsRuntime {
+ constructor(props) {
+ this.runtime = props?.runtime || 'vm2';
+ this.mode = props?.mode || 'developer';
+ }
+
+ runPostResponseVars(vars, request, response, envVariables, runtimeVariables, collectionPath, processEnvVars) {
+ const requestVariables = request?.requestVariables || {};
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
const enabledVars = _.filter(vars, (v) => v.enabled);
if (!enabledVars.length) {
return;
}
- const bru = new Bru(envVariables, collectionVariables, processEnvVars);
+ const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables);
const req = new BrunoRequest(request);
const res = createResponseParser(response);
@@ -52,18 +47,34 @@ class VarsRuntime {
const context = {
...envVariables,
- ...collectionVariables,
+ ...runtimeVariables,
...bruContext
};
+ const errors = new Map();
_.each(enabledVars, (v) => {
- const value = evaluateJsExpression(v.value, context);
- bru.setVar(v.name, value);
+ try {
+ const value = evaluateJsExpressionBasedOnRuntime(v.value, context, this.runtime);
+ if (v.name) {
+ bru.setVar(v.name, value);
+ }
+ } catch (error) {
+ errors.set(v.name, error);
+ }
});
+ let error = null;
+ if (errors.size > 0) {
+ // Format all errors as a single string to be displayed in a toast
+ const errorMessage = [...errors.entries()].map(([name, err]) => `${name}: ${err.message ?? err}`).join('\n');
+ error = `${errors.size} error${errors.size === 1 ? '' : 's'} in post response variables: \n${errorMessage}`;
+ }
+
return {
envVariables,
- collectionVariables
+ runtimeVariables,
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ error
};
}
}
diff --git a/packages/bruno-js/src/sandbox/bundle-libraries.js b/packages/bruno-js/src/sandbox/bundle-libraries.js
new file mode 100644
index 000000000..1545ef5cd
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/bundle-libraries.js
@@ -0,0 +1,91 @@
+const rollup = require('rollup');
+const { nodeResolve } = require('@rollup/plugin-node-resolve');
+const commonjs = require('@rollup/plugin-commonjs');
+const fs = require('fs');
+const { terser } = require('rollup-plugin-terser');
+
+const bundleLibraries = async () => {
+ const codeScript = `
+ import { expect, assert } from 'chai';
+ import { Buffer } from "buffer";
+ import moment from "moment";
+ import btoa from "btoa";
+ import atob from "atob";
+ import * as CryptoJS from "@usebruno/crypto-js";
+ import tv4 from "tv4";
+ globalThis.expect = expect;
+ globalThis.assert = assert;
+ globalThis.moment = moment;
+ globalThis.btoa = btoa;
+ globalThis.atob = atob;
+ globalThis.Buffer = Buffer;
+ globalThis.CryptoJS = CryptoJS;
+ globalThis.tv4 = tv4;
+ globalThis.requireObject = {
+ ...(globalThis.requireObject || {}),
+ 'chai': { expect, assert },
+ 'moment': moment,
+ 'buffer': { Buffer },
+ 'btoa': btoa,
+ 'atob': atob,
+ 'crypto-js': CryptoJS,
+ 'tv4': tv4
+ };
+`;
+
+ const config = {
+ input: {
+ input: 'inline-code',
+ plugins: [
+ {
+ name: 'inline-code-plugin',
+ resolveId(id) {
+ if (id === 'inline-code') {
+ return id;
+ }
+ return null;
+ },
+ load(id) {
+ if (id === 'inline-code') {
+ return codeScript;
+ }
+ return null;
+ }
+ },
+ nodeResolve({
+ preferBuiltins: false,
+ browser: false
+ }),
+ commonjs(),
+ terser()
+ ]
+ },
+ output: {
+ file: './src/sandbox/bundle-browser-rollup.js',
+ format: 'iife',
+ name: 'MyBundle'
+ }
+ };
+
+ try {
+ const bundle = await rollup.rollup(config.input);
+ const { output } = await bundle.generate(config.output);
+ fs.writeFileSync(
+ './src/sandbox/bundle-browser-rollup.js',
+ `
+ const getBundledCode = () => {
+ return function(){
+ ${output?.map((o) => o.code).join('\n')}
+ }()
+ }
+ module.exports = getBundledCode;
+ `
+ );
+ } catch (error) {
+ console.error('Error while bundling:', error);
+ }
+};
+
+bundleLibraries();
+
+module.exports = bundleLibraries;
diff --git a/packages/bruno-js/src/sandbox/quickjs/index.js b/packages/bruno-js/src/sandbox/quickjs/index.js
new file mode 100644
index 000000000..2c83c0e3f
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/index.js
@@ -0,0 +1,191 @@
+const addBruShimToContext = require('./shims/bru');
+const addBrunoRequestShimToContext = require('./shims/bruno-request');
+const addConsoleShimToContext = require('./shims/console');
+const addBrunoResponseShimToContext = require('./shims/bruno-response');
+const addTestShimToContext = require('./shims/test');
+const addLibraryShimsToContext = require('./shims/lib');
+const addLocalModuleLoaderShimToContext = require('./shims/local-module');
+const { newQuickJSWASMModule, memoizePromiseFactory } = require('quickjs-emscripten');
+
+// execute `npm run sandbox:bundle-libraries` if the below file doesn't exist
+const getBundledCode = require('../bundle-browser-rollup');
+const addPathShimToContext = require('./shims/lib/path');
+const { marshallToVm } = require('./utils');
+
+let QuickJSSyncContext;
+const loader = memoizePromiseFactory(() => newQuickJSWASMModule());
+const getContext = (opts) => loader().then((mod) => (QuickJSSyncContext = mod.newContext(opts)));
+getContext();
+
+const toNumber = (value) => {
+ const num = Number(value);
+ return Number.isInteger(num) ? parseInt(value, 10) : parseFloat(value);
+};
+
+const removeQuotes = (str) => {
+ if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
+ return str.slice(1, -1);
+ }
+ return str;
+};
+
+const executeQuickJsVm = ({ script: externalScript, context: externalContext, scriptType = 'template-literal' }) => {
+ if (!externalScript?.length || typeof externalScript !== 'string') {
+ return externalScript;
+ }
+ externalScript = externalScript?.trim();
+
+ if(scriptType === 'template-literal') {
+ if (!isNaN(Number(externalScript))) {
+ const number = Number(externalScript);
+
+ // Check if the number is too high. Too high number might get altered, see #1000
+ if (number > Number.MAX_SAFE_INTEGER) {
+ return externalScript;
+ }
+
+ return toNumber(externalScript);
+ }
+
+ if (externalScript === 'true') return true;
+ if (externalScript === 'false') return false;
+ if (externalScript === 'null') return null;
+ if (externalScript === 'undefined') return undefined;
+
+ externalScript = removeQuotes(externalScript);
+ }
+
+ const vm = QuickJSSyncContext;
+
+ try {
+ const { bru, req, res, ...variables } = externalContext;
+
+ bru && addBruShimToContext(vm, bru);
+ req && addBrunoRequestShimToContext(vm, req);
+ res && addBrunoResponseShimToContext(vm, res);
+
+ Object.entries(variables)?.forEach(([key, value]) => {
+ vm.setProp(vm.global, key, marshallToVm(value, vm));
+ });
+
+ const templateLiteralText = `\`${externalScript}\``;
+ const jsExpressionText = `${externalScript}`;
+
+ let scriptText = scriptType === 'template-literal' ? templateLiteralText : jsExpressionText;
+
+ const result = vm.evalCode(scriptText);
+ if (result.error) {
+ let e = vm.dump(result.error);
+ result.error.dispose();
+ return e;
+ } else {
+ let v = vm.dump(result.value);
+ result.value.dispose();
+ return v;
+ }
+ } catch (error) {
+ console.error('Error executing the script!', error);
+ }
+};
+
+const executeQuickJsVmAsync = async ({ script: externalScript, context: externalContext, collectionPath }) => {
+ if (!externalScript?.length || typeof externalScript !== 'string') {
+ return externalScript;
+ }
+ externalScript = externalScript?.trim();
+
+ try {
+ const module = await newQuickJSWASMModule();
+ const vm = module.newContext();
+
+ const bundledCode = getBundledCode?.toString() || '';
+ const moduleLoaderCode = function () {
+ return `
+ globalThis.require = (mod) => {
+ let lib = globalThis.requireObject[mod];
+ let isModuleAPath = (module) => (module?.startsWith('.') || module?.startsWith?.(bru.cwd()))
+ if (lib) {
+ return lib;
+ }
+ else if (isModuleAPath(mod)) {
+ // fetch local module
+ let localModuleCode = globalThis.__brunoLoadLocalModule(mod);
+
+ // compile local module as iife
+ (function (){
+ const initModuleExportsCode = "const module = { exports: {} };"
+ const copyModuleExportsCode = "\\n;globalThis.requireObject[mod] = module.exports;";
+ const patchedRequire = ${`
+ "\\n;" +
+ "let require = (subModule) => isModuleAPath(subModule) ? globalThis.require(path.resolve(bru.cwd(), mod, '..', subModule)) : globalThis.require(subModule)" +
+ "\\n;"
+ `}
+ eval(initModuleExportsCode + patchedRequire + localModuleCode + copyModuleExportsCode);
+ })();
+
+ // resolve module
+ return globalThis.requireObject[mod];
+ }
+ else {
+ throw new Error("Cannot find module " + mod);
+ }
+ }
+ `;
+ };
+
+ vm.evalCode(
+ `
+ (${bundledCode})()
+ ${moduleLoaderCode()}
+ `
+ );
+
+ const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext;
+
+ bru && addBruShimToContext(vm, bru);
+ req && addBrunoRequestShimToContext(vm, req);
+ res && addBrunoResponseShimToContext(vm, res);
+ consoleFn && addConsoleShimToContext(vm, consoleFn);
+ addLocalModuleLoaderShimToContext(vm, collectionPath);
+ addPathShimToContext(vm);
+
+ await addLibraryShimsToContext(vm);
+
+ test && __brunoTestResults && addTestShimToContext(vm, __brunoTestResults);
+
+ const script = `
+ (async () => {
+ const setTimeout = async(fn, timer) => {
+ v = await bru.sleep(timer);
+ fn.apply();
+ }
+ await bru.sleep(0);
+ try {
+ ${externalScript}
+ }
+ catch(error) {
+ console?.debug?.('quick-js:execution-end:with-error', error?.message);
+ throw new Error(error?.message);
+ }
+ return 'done';
+ })()
+ `;
+
+ const result = vm.evalCode(script);
+ const promiseHandle = vm.unwrapResult(result);
+ const resolvedResult = await vm.resolvePromise(promiseHandle);
+ promiseHandle.dispose();
+ const resolvedHandle = vm.unwrapResult(resolvedResult);
+ resolvedHandle.dispose();
+ // vm.dispose();
+ return;
+ } catch (error) {
+ console.error('Error executing the script!', error);
+ throw new Error(error);
+ }
+};
+
+module.exports = {
+ executeQuickJsVm,
+ executeQuickJsVmAsync
+};
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
new file mode 100644
index 000000000..8439d7206
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
@@ -0,0 +1,247 @@
+const { cleanJson } = require('../../../utils');
+const { marshallToVm } = require('../utils');
+
+const addBruShimToContext = (vm, bru) => {
+ const bruObject = vm.newObject();
+ const bruRunnerObject = vm.newObject();
+
+ let cwd = vm.newFunction('cwd', function () {
+ return marshallToVm(bru.cwd(), vm);
+ });
+ vm.setProp(bruObject, 'cwd', cwd);
+ cwd.dispose();
+
+ let getEnvName = vm.newFunction('getEnvName', function () {
+ return marshallToVm(bru.getEnvName(), vm);
+ });
+ vm.setProp(bruObject, 'getEnvName', getEnvName);
+ getEnvName.dispose();
+
+ let getCollectionName = vm.newFunction('getCollectionName', function () {
+ return marshallToVm(bru.getCollectionName(), vm);
+ });
+ vm.setProp(bruObject, 'getCollectionName', getCollectionName);
+ getCollectionName.dispose();
+
+ let getProcessEnv = vm.newFunction('getProcessEnv', function (key) {
+ return marshallToVm(bru.getProcessEnv(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getProcessEnv', getProcessEnv);
+ getProcessEnv.dispose();
+
+ let interpolate = vm.newFunction('interpolate', function (str) {
+ return marshallToVm(bru.interpolate(vm.dump(str)), vm);
+ });
+ vm.setProp(bruObject, 'interpolate', interpolate);
+ interpolate.dispose();
+
+ let hasEnvVar = vm.newFunction('hasEnvVar', function (key) {
+ return marshallToVm(bru.hasEnvVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'hasEnvVar', hasEnvVar);
+ hasEnvVar.dispose();
+
+ let getEnvVar = vm.newFunction('getEnvVar', function (key) {
+ return marshallToVm(bru.getEnvVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getEnvVar', getEnvVar);
+ getEnvVar.dispose();
+
+ let setEnvVar = vm.newFunction('setEnvVar', function (key, value) {
+ bru.setEnvVar(vm.dump(key), vm.dump(value));
+ });
+ vm.setProp(bruObject, 'setEnvVar', setEnvVar);
+ setEnvVar.dispose();
+
+ let deleteEnvVar = vm.newFunction('deleteEnvVar', function (key) {
+ return marshallToVm(bru.deleteEnvVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'deleteEnvVar', deleteEnvVar);
+ deleteEnvVar.dispose();
+
+ let getGlobalEnvVar = vm.newFunction('getGlobalEnvVar', function (key) {
+ return marshallToVm(bru.getGlobalEnvVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getGlobalEnvVar', getGlobalEnvVar);
+ getGlobalEnvVar.dispose();
+
+ let getOauth2CredentialVar = vm.newFunction('getOauth2CredentialVar', function (key) {
+ return marshallToVm(bru.getOauth2CredentialVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getOauth2CredentialVar', getOauth2CredentialVar);
+ getOauth2CredentialVar.dispose();
+
+ let setGlobalEnvVar = vm.newFunction('setGlobalEnvVar', function (key, value) {
+ bru.setGlobalEnvVar(vm.dump(key), vm.dump(value));
+ });
+ vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
+ setGlobalEnvVar.dispose();
+
+ let hasVar = vm.newFunction('hasVar', function (key) {
+ return marshallToVm(bru.hasVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'hasVar', hasVar);
+ hasVar.dispose();
+
+ let getVar = vm.newFunction('getVar', function (key) {
+ return marshallToVm(bru.getVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getVar', getVar);
+ getVar.dispose();
+
+ let setVar = vm.newFunction('setVar', function (key, value) {
+ bru.setVar(vm.dump(key), vm.dump(value));
+ });
+ vm.setProp(bruObject, 'setVar', setVar);
+ setVar.dispose();
+
+ let deleteVar = vm.newFunction('deleteVar', function (key) {
+ bru.deleteVar(vm.dump(key));
+ });
+ vm.setProp(bruObject, 'deleteVar', deleteVar);
+ deleteVar.dispose();
+
+ let deleteAllVars = vm.newFunction('deleteAllVars', function () {
+ bru.deleteAllVars();
+ });
+ vm.setProp(bruObject, 'deleteAllVars', deleteAllVars);
+ deleteAllVars.dispose();
+
+ let setNextRequest = vm.newFunction('setNextRequest', function (nextRequest) {
+ bru.setNextRequest(vm.dump(nextRequest));
+ });
+ vm.setProp(bruObject, 'setNextRequest', setNextRequest);
+ setNextRequest.dispose();
+
+ let runnerSkipRequest = vm.newFunction('skipRequest', function () {
+ bru?.runner?.skipRequest();
+ });
+ vm.setProp(bruRunnerObject, 'skipRequest', runnerSkipRequest);
+ runnerSkipRequest.dispose();
+
+ let runnerStopExecution = vm.newFunction('stopExecution', function () {
+ bru?.runner?.stopExecution();
+ });
+ vm.setProp(bruRunnerObject, 'stopExecution', runnerStopExecution);
+ runnerStopExecution.dispose();
+
+ let runnerSetNextRequest = vm.newFunction('setNextRequest', function (nextRequest) {
+ bru?.runner?.setNextRequest(vm.dump(nextRequest));
+ });
+ vm.setProp(bruRunnerObject, 'setNextRequest', runnerSetNextRequest);
+ runnerSetNextRequest.dispose();
+
+ let visualize = vm.newFunction('visualize', function (htmlString) {
+ bru.visualize(vm.dump(htmlString));
+ });
+ vm.setProp(bruObject, 'visualize', visualize);
+ visualize.dispose();
+
+ let getSecretVar = vm.newFunction('getSecretVar', function (key) {
+ return marshallToVm(bru.getSecretVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getSecretVar', getSecretVar);
+ getSecretVar.dispose();
+
+ let getRequestVar = vm.newFunction('getRequestVar', function (key) {
+ return marshallToVm(bru.getRequestVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getRequestVar', getRequestVar);
+ getRequestVar.dispose();
+
+ let getFolderVar = vm.newFunction('getFolderVar', function (key) {
+ return marshallToVm(bru.getFolderVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getFolderVar', getFolderVar);
+ getFolderVar.dispose();
+
+ let getCollectionVar = vm.newFunction('getCollectionVar', function (key) {
+ return marshallToVm(bru.getCollectionVar(vm.dump(key)), vm);
+ });
+ vm.setProp(bruObject, 'getCollectionVar', getCollectionVar);
+ getCollectionVar.dispose();
+
+ let getTestResults = vm.newFunction('getTestResults', () => {
+ const promise = vm.newPromise();
+ bru
+ .getTestResults()
+ .then((results) => {
+ promise.resolve(marshallToVm(cleanJson(results), vm));
+ })
+ .catch((err) => {
+ promise.resolve(
+ marshallToVm(
+ cleanJson({
+ message: err.message
+ }),
+ vm
+ )
+ );
+ });
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ getTestResults.consume((handle) => vm.setProp(bruObject, 'getTestResults', handle));
+
+ let getAssertionResults = vm.newFunction('getAssertionResults', () => {
+ const promise = vm.newPromise();
+ bru
+ .getAssertionResults()
+ .then((results) => {
+ promise.resolve(marshallToVm(cleanJson(results), vm));
+ })
+ .catch((err) => {
+ promise.resolve(
+ marshallToVm(
+ cleanJson({
+ message: err.message
+ }),
+ vm
+ )
+ );
+ });
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ getAssertionResults.consume((handle) => vm.setProp(bruObject, 'getAssertionResults', handle));
+
+ let runRequestHandle = vm.newFunction('runRequest', (args) => {
+ const promise = vm.newPromise();
+ bru
+ .runRequest(vm.dump(args))
+ .then((response) => {
+ const { status, headers, data, dataBuffer, size, statusText } = response || {};
+ promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));
+ })
+ .catch((err) => {
+ promise.resolve(
+ marshallToVm(
+ cleanJson({
+ message: err.message
+ }),
+ vm
+ )
+ );
+ });
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ runRequestHandle.consume((handle) => vm.setProp(bruObject, 'runRequest', handle));
+
+ const sleep = vm.newFunction('sleep', (timer) => {
+ const t = vm.getString(timer);
+ const promise = vm.newPromise();
+ setTimeout(() => {
+ promise.resolve(vm.newString('slept'));
+ }, t);
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ sleep.consume((handle) => vm.setProp(bruObject, 'sleep', handle));
+
+ vm.setProp(bruObject, 'runner', bruRunnerObject);
+ vm.setProp(vm.global, 'bru', bruObject);
+ bruObject.dispose();
+};
+
+module.exports = addBruShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js
new file mode 100644
index 000000000..4a51ae580
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js
@@ -0,0 +1,133 @@
+const { marshallToVm } = require('../utils');
+
+const addBrunoRequestShimToContext = (vm, req) => {
+ const reqObject = vm.newObject();
+
+ const url = marshallToVm(req.getUrl(), vm);
+ const method = marshallToVm(req.getMethod(), vm);
+ const headers = marshallToVm(req.getHeaders(), vm);
+ const body = marshallToVm(req.getBody(), vm);
+ const timeout = marshallToVm(req.getTimeout(), vm);
+ const name = marshallToVm(req.getName(), vm);
+
+ vm.setProp(reqObject, 'url', url);
+ vm.setProp(reqObject, 'method', method);
+ vm.setProp(reqObject, 'headers', headers);
+ vm.setProp(reqObject, 'body', body);
+ vm.setProp(reqObject, 'timeout', timeout);
+ vm.setProp(reqObject, 'name', name);
+
+ url.dispose();
+ method.dispose();
+ headers.dispose();
+ body.dispose();
+ timeout.dispose();
+ name.dispose();
+
+ let getUrl = vm.newFunction('getUrl', function () {
+ return marshallToVm(req.getUrl(), vm);
+ });
+ vm.setProp(reqObject, 'getUrl', getUrl);
+ getUrl.dispose();
+
+ let setUrl = vm.newFunction('setUrl', function (url) {
+ req.setUrl(vm.dump(url));
+ });
+ vm.setProp(reqObject, 'setUrl', setUrl);
+ setUrl.dispose();
+
+ let getMethod = vm.newFunction('getMethod', function () {
+ return marshallToVm(req.getMethod(), vm);
+ });
+ vm.setProp(reqObject, 'getMethod', getMethod);
+ getMethod.dispose();
+
+ let getAuthMode = vm.newFunction('getAuthMode', function () {
+ return marshallToVm(req.getAuthMode(), vm);
+ });
+ vm.setProp(reqObject, 'getAuthMode', getAuthMode);
+ getAuthMode.dispose();
+
+ let getName = vm.newFunction('getName', function () {
+ return marshallToVm(req.getName(), vm);
+ });
+ vm.setProp(reqObject, 'getName', getName);
+ getName.dispose();
+
+ let setMethod = vm.newFunction('setMethod', function (method) {
+ req.setMethod(vm.dump(method));
+ });
+ vm.setProp(reqObject, 'setMethod', setMethod);
+ setMethod.dispose();
+
+ let getHeaders = vm.newFunction('getHeaders', function () {
+ return marshallToVm(req.getHeaders(), vm);
+ });
+ vm.setProp(reqObject, 'getHeaders', getHeaders);
+ getHeaders.dispose();
+
+ let setHeaders = vm.newFunction('setHeaders', function (headers) {
+ req.setHeaders(vm.dump(headers));
+ });
+ vm.setProp(reqObject, 'setHeaders', setHeaders);
+ setHeaders.dispose();
+
+ let getHeader = vm.newFunction('getHeader', function (name) {
+ return marshallToVm(req.getHeader(vm.dump(name)), vm);
+ });
+ vm.setProp(reqObject, 'getHeader', getHeader);
+ getHeader.dispose();
+
+ let setHeader = vm.newFunction('setHeader', function (name, value) {
+ req.setHeader(vm.dump(name), vm.dump(value));
+ });
+ vm.setProp(reqObject, 'setHeader', setHeader);
+ setHeader.dispose();
+
+ let getBody = vm.newFunction('getBody', function () {
+ return marshallToVm(req.getBody(), vm);
+ });
+ vm.setProp(reqObject, 'getBody', getBody);
+ getBody.dispose();
+
+ let setBody = vm.newFunction('setBody', function (data) {
+ req.setBody(vm.dump(data));
+ });
+ vm.setProp(reqObject, 'setBody', setBody);
+ setBody.dispose();
+
+ let setMaxRedirects = vm.newFunction('setMaxRedirects', function (maxRedirects) {
+ req.setMaxRedirects(vm.dump(maxRedirects));
+ });
+ vm.setProp(reqObject, 'setMaxRedirects', setMaxRedirects);
+ setMaxRedirects.dispose();
+
+ let getTimeout = vm.newFunction('getTimeout', function () {
+ return marshallToVm(req.getTimeout(), vm);
+ });
+ vm.setProp(reqObject, 'getTimeout', getTimeout);
+ getTimeout.dispose();
+
+ let setTimeout = vm.newFunction('setTimeout', function (timeout) {
+ req.setTimeout(vm.dump(timeout));
+ });
+ vm.setProp(reqObject, 'setTimeout', setTimeout);
+ setTimeout.dispose();
+
+ let disableParsingResponseJson = vm.newFunction('disableParsingResponseJson', function () {
+ req.disableParsingResponseJson();
+ });
+ vm.setProp(reqObject, 'disableParsingResponseJson', disableParsingResponseJson);
+ disableParsingResponseJson.dispose();
+
+ let getExecutionMode = vm.newFunction('getExecutionMode', function () {
+ return marshallToVm(req.getExecutionMode(), vm);
+ });
+ vm.setProp(reqObject, 'getExecutionMode', getExecutionMode);
+ getExecutionMode.dispose();
+
+ vm.setProp(vm.global, 'req', reqObject);
+ reqObject.dispose();
+};
+
+module.exports = addBrunoRequestShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
new file mode 100644
index 000000000..6b9501876
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
@@ -0,0 +1,72 @@
+const { marshallToVm } = require('../utils');
+
+const addBrunoResponseShimToContext = (vm, res) => {
+ let resFn = vm.newFunction('res', function (exprStr) {
+ return marshallToVm(res(vm.dump(exprStr)), vm);
+ });
+
+ const status = marshallToVm(res?.status, vm);
+ const statusText = marshallToVm(res?.statusText, vm);
+ const headers = marshallToVm(res?.headers, vm);
+ const body = marshallToVm(res?.body, vm);
+ const responseTime = marshallToVm(res?.responseTime, vm);
+
+ vm.setProp(resFn, 'status', status);
+ vm.setProp(resFn, 'statusText', statusText);
+ vm.setProp(resFn, 'headers', headers);
+ vm.setProp(resFn, 'body', body);
+ vm.setProp(resFn, 'responseTime', responseTime);
+
+ status.dispose();
+ headers.dispose();
+ body.dispose();
+ responseTime.dispose();
+ statusText.dispose();
+
+ let getStatusText = vm.newFunction('getStatusText', function () {
+ return marshallToVm(res.getStatusText(), vm);
+ });
+ vm.setProp(resFn, 'getStatusText', getStatusText);
+ getStatusText.dispose();
+
+ let getStatus = vm.newFunction('getStatus', function () {
+ return marshallToVm(res.getStatus(), vm);
+ });
+ vm.setProp(resFn, 'getStatus', getStatus);
+ getStatus.dispose();
+
+ let getHeader = vm.newFunction('getHeader', function (name) {
+ return marshallToVm(res.getHeader(vm.dump(name)), vm);
+ });
+ vm.setProp(resFn, 'getHeader', getHeader);
+ getHeader.dispose();
+
+ let getHeaders = vm.newFunction('getHeaders', function () {
+ return marshallToVm(res.getHeaders(), vm);
+ });
+ vm.setProp(resFn, 'getHeaders', getHeaders);
+ getHeaders.dispose();
+
+ let getBody = vm.newFunction('getBody', function () {
+ return marshallToVm(res.getBody(), vm);
+ });
+ vm.setProp(resFn, 'getBody', getBody);
+ getBody.dispose();
+
+ let getResponseTime = vm.newFunction('getResponseTime', function () {
+ return marshallToVm(res.getResponseTime(), vm);
+ });
+ vm.setProp(resFn, 'getResponseTime', getResponseTime);
+ getResponseTime.dispose();
+
+ let setBody = vm.newFunction('setBody', function (data) {
+ res.setBody(vm.dump(data));
+ });
+ vm.setProp(resFn, 'setBody', setBody);
+ setBody.dispose();
+
+ vm.setProp(vm.global, 'res', resFn);
+ resFn.dispose();
+};
+
+module.exports = addBrunoResponseShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/console.js b/packages/bruno-js/src/sandbox/quickjs/shims/console.js
new file mode 100644
index 000000000..984422893
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/console.js
@@ -0,0 +1,46 @@
+const addConsoleShimToContext = (vm, console) => {
+ if (!console) return;
+
+ const consoleHandle = vm.newObject();
+
+ const logHandle = vm.newFunction('log', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ console?.log?.(...nativeArgs);
+ });
+
+ const debugHandle = vm.newFunction('debug', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ console?.debug?.(...nativeArgs);
+ });
+
+ const infoHandle = vm.newFunction('info', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ console?.info?.(...nativeArgs);
+ });
+
+ const warnHandle = vm.newFunction('warn', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ console?.warn?.(...nativeArgs);
+ });
+
+ const errorHandle = vm.newFunction('error', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ console?.error?.(...nativeArgs);
+ });
+
+ vm.setProp(consoleHandle, 'log', logHandle);
+ vm.setProp(consoleHandle, 'debug', debugHandle);
+ vm.setProp(consoleHandle, 'info', infoHandle);
+ vm.setProp(consoleHandle, 'warn', warnHandle);
+ vm.setProp(consoleHandle, 'error', errorHandle);
+
+ vm.setProp(vm.global, 'console', consoleHandle);
+ consoleHandle.dispose();
+ logHandle.dispose();
+ debugHandle.dispose();
+ infoHandle.dispose();
+ warnHandle.dispose();
+ errorHandle.dispose();
+};
+
+module.exports = addConsoleShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js
new file mode 100644
index 000000000..2f0fc0789
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js
@@ -0,0 +1,72 @@
+const axios = require('axios');
+const { cleanJson } = require('../../../../utils');
+const { marshallToVm } = require('../../utils');
+
+const methods = ['get', 'post', 'put', 'patch', 'delete'];
+
+const addAxiosShimToContext = async (vm) => {
+ methods?.forEach((method) => {
+ const axiosHandle = vm.newFunction(method, (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ const promise = vm.newPromise();
+ axios[method](...nativeArgs)
+ .then((response) => {
+ const { status, headers, data } = response || {};
+ promise.resolve(marshallToVm(cleanJson({ status, headers, data }), vm));
+ })
+ .catch((err) => {
+ promise.resolve(
+ marshallToVm(
+ cleanJson({
+ message: err.message
+ }),
+ vm
+ )
+ );
+ });
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ axiosHandle.consume((handle) => vm.setProp(vm.global, `__bruno__axios__${method}`, handle));
+ });
+
+ const axiosHandle = vm.newFunction('axios', (...args) => {
+ const nativeArgs = args.map(vm.dump);
+ const promise = vm.newPromise();
+ axios(...nativeArgs)
+ .then((response) => {
+ const { status, headers, data } = response || {};
+ promise.resolve(marshallToVm(cleanJson({ status, headers, data }), vm));
+ })
+ .catch((err) => {
+ promise.resolve(
+ marshallToVm(
+ cleanJson({
+ message: err.message
+ }),
+ vm
+ )
+ );
+ });
+ promise.settled.then(vm.runtime.executePendingJobs);
+ return promise.handle;
+ });
+ axiosHandle.consume((handle) => vm.setProp(vm.global, `__bruno__axios`, handle));
+
+ vm.evalCode(
+ `
+ globalThis.axios = __bruno__axios;
+ ${methods
+ ?.map((method) => {
+ return `globalThis.axios.${method} = __bruno__axios__${method};`;
+ })
+ ?.join('\n')}
+ globalThis.requireObject = {
+ ...globalThis.requireObject,
+ axios: globalThis.axios,
+ }
+ `
+ );
+};
+
+module.exports = addAxiosShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js
new file mode 100644
index 000000000..64f239c7f
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/index.js
@@ -0,0 +1,13 @@
+const addAxiosShimToContext = require('./axios');
+const addNanoidShimToContext = require('./nanoid');
+const addPathShimToContext = require('./path');
+const addUuidShimToContext = require('./uuid');
+
+const addLibraryShimsToContext = async (vm) => {
+ await addNanoidShimToContext(vm);
+ await addAxiosShimToContext(vm);
+ await addUuidShimToContext(vm);
+ await addPathShimToContext(vm);
+};
+
+module.exports = addLibraryShimsToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/nanoid.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/nanoid.js
new file mode 100644
index 000000000..7a83d37fe
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/nanoid.js
@@ -0,0 +1,24 @@
+const { nanoid } = require('nanoid');
+const { marshallToVm } = require('../../utils');
+
+const addNanoidShimToContext = async (vm) => {
+ let _nanoid = vm.newFunction('nanoid', function () {
+ let v = nanoid();
+ return marshallToVm(v, vm);
+ });
+ vm.setProp(vm.global, '__bruno__nanoid', _nanoid);
+ _nanoid.dispose();
+
+ vm.evalCode(
+ `
+ globalThis.nanoid = {};
+ globalThis.nanoid.nanoid = globalThis.__bruno__nanoid;
+ globalThis.requireObject = {
+ ...globalThis.requireObject,
+ 'nanoid': globalThis.nanoid
+ }
+ `
+ );
+};
+
+module.exports = addNanoidShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/path.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/path.js
new file mode 100644
index 000000000..8c9b2e02e
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/path.js
@@ -0,0 +1,28 @@
+const path = require('path');
+const { marshallToVm } = require('../../utils');
+
+const fns = ['resolve'];
+
+const addPathShimToContext = async (vm) => {
+ fns.forEach((fn) => {
+ let fnHandle = vm.newFunction(fn, function (...args) {
+ const nativeArgs = args.map(vm.dump);
+ return marshallToVm(path[fn](...nativeArgs), vm);
+ });
+ vm.setProp(vm.global, `__bruno__path__${fn}`, fnHandle);
+ fnHandle.dispose();
+ });
+
+ vm.evalCode(
+ `
+ globalThis.path = {};
+ ${fns?.map((fn, idx) => `globalThis.path.${fn} = __bruno__path__${fn}`).join('\n')}
+ globalThis.requireObject = {
+ ...(globalThis.requireObject || {}),
+ path: globalThis.path,
+ }
+ `
+ );
+};
+
+module.exports = addPathShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/uuid.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/uuid.js
new file mode 100644
index 000000000..23f830311
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/uuid.js
@@ -0,0 +1,30 @@
+const uuid = require('uuid');
+const { marshallToVm } = require('../../utils');
+
+const fns = ['version', 'parse', 'stringify', 'v1', 'v1ToV6', 'v3', 'v4', 'v5', 'v6', 'v6ToV1', 'v7', 'validate'];
+
+const addUuidShimToContext = async (vm) => {
+ fns.forEach((fn) => {
+ let fnHandle = vm.newFunction(fn, function (...args) {
+ const nativeArgs = args.map(vm.dump);
+ return marshallToVm(uuid[fn](...nativeArgs), vm);
+ });
+ vm.setProp(vm.global, `__bruno__uuid__${fn}`, fnHandle);
+ fnHandle.dispose();
+ });
+
+ vm.evalCode(
+ `
+ globalThis.uuid = {};
+ ${['version', 'parse', 'stringify', 'v1', 'v1ToV6', 'v3', 'v4', 'v5', 'v6', 'v6ToV1', 'v7', 'validate']
+ ?.map((fn, idx) => `globalThis.uuid.${fn} = __bruno__uuid__${fn}`)
+ .join('\n')}
+ globalThis.requireObject = {
+ ...globalThis.requireObject,
+ uuid: globalThis.uuid,
+ }
+ `
+ );
+};
+
+module.exports = addUuidShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/local-module.js b/packages/bruno-js/src/sandbox/quickjs/shims/local-module.js
new file mode 100644
index 000000000..ca2b85730
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/local-module.js
@@ -0,0 +1,35 @@
+const path = require('path');
+const fs = require('fs');
+const { marshallToVm } = require('../utils');
+
+const addLocalModuleLoaderShimToContext = (vm, collectionPath) => {
+ let loadLocalModuleHandle = vm.newFunction('loadLocalModule', function (module) {
+ const filename = vm.dump(module);
+
+ // Check if the filename has an extension
+ const hasExtension = path.extname(filename) !== '';
+ const resolvedFilename = hasExtension ? filename : `${filename}.js`;
+
+ // Resolve the file path and check if it's within the collectionPath
+ const filePath = path.resolve(collectionPath, resolvedFilename);
+ const relativePath = path.relative(collectionPath, filePath);
+
+ // Ensure the resolved file path is inside the collectionPath
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
+ throw new Error('Access to files outside of the collectionPath is not allowed.');
+ }
+
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`Cannot find module ${filename}`);
+ }
+
+ let code = fs.readFileSync(filePath).toString();
+
+ return marshallToVm(code, vm);
+ });
+
+ vm.setProp(vm.global, '__brunoLoadLocalModule', loadLocalModuleHandle);
+ loadLocalModuleHandle.dispose();
+};
+
+module.exports = addLocalModuleLoaderShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js
new file mode 100644
index 000000000..9da224a39
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js
@@ -0,0 +1,63 @@
+const { marshallToVm } = require('../utils');
+
+const addBruShimToContext = (vm, __brunoTestResults) => {
+ let addResult = vm.newFunction('addResult', function (v) {
+ __brunoTestResults.addResult(vm.dump(v));
+ });
+ vm.setProp(vm.global, '__bruno__addResult', addResult);
+ addResult.dispose();
+
+ let getResults = vm.newFunction('getResults', function () {
+ return marshallToVm(__brunoTestResults.getResults(), vm);
+ });
+ vm.setProp(vm.global, '__bruno__getResults', getResults);
+ getResults.dispose();
+
+ vm.evalCode(
+ `
+ globalThis.expect = require('chai').expect;
+ globalThis.assert = require('chai').assert;
+
+ globalThis.__brunoTestResults = {
+ addResult: globalThis.__bruno__addResult,
+ getResults: globalThis.__bruno__getResults,
+ }
+
+ globalThis.DummyChaiAssertionError = class DummyChaiAssertionError extends Error {
+ constructor(message, props, ssf) {
+ super(message);
+ this.name = "AssertionError";
+ Object.assign(this, props);
+ }
+ }
+
+ globalThis.Test = (__brunoTestResults) => async (description, callback) => {
+ try {
+ await callback();
+ __brunoTestResults.addResult({ description, status: "pass" });
+ } catch (error) {
+ if (error instanceof DummyChaiAssertionError) {
+ const { message, actual, expected } = error;
+ __brunoTestResults.addResult({
+ description,
+ status: "fail",
+ error: message,
+ actual,
+ expected,
+ });
+ } else {
+ globalThis.__bruno__addResult({
+ description,
+ status: "fail",
+ error: error.message || "An unexpected error occurred.",
+ });
+ }
+ }
+ };
+
+ globalThis.test = Test(__brunoTestResults);
+ `
+ );
+};
+
+module.exports = addBruShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/utils/index.js b/packages/bruno-js/src/sandbox/quickjs/utils/index.js
new file mode 100644
index 000000000..47be92f5f
--- /dev/null
+++ b/packages/bruno-js/src/sandbox/quickjs/utils/index.js
@@ -0,0 +1,35 @@
+const marshallToVm = (value, vm) => {
+ if (value === undefined) {
+ return vm.undefined;
+ }
+ if (value === null) {
+ return vm.null;
+ }
+ if (typeof value === 'string') {
+ return vm.newString(value);
+ } else if (typeof value === 'number') {
+ return vm.newNumber(value);
+ } else if (typeof value === 'boolean') {
+ return value ? vm.true : vm.false;
+ } else if (typeof value === 'object') {
+ if (Array.isArray(value)) {
+ const arr = vm.newArray();
+ for (let i = 0; i < value.length; i++) {
+ vm.setProp(arr, i, marshallToVm(value[i], vm));
+ }
+ return arr;
+ } else {
+ const obj = vm.newObject();
+ for (const key in value) {
+ vm.setProp(obj, key, marshallToVm(value[key], vm));
+ }
+ return obj;
+ }
+ } else if (typeof value === 'function') {
+ return vm.newString('[Function (anonymous)]');
+ }
+};
+
+module.exports = {
+ marshallToVm
+};
diff --git a/packages/bruno-js/src/utils.js b/packages/bruno-js/src/utils.js
index e15ec09a7..289bf8dcc 100644
--- a/packages/bruno-js/src/utils.js
+++ b/packages/bruno-js/src/utils.js
@@ -18,8 +18,8 @@ const JS_KEYWORDS = `
* ```js
* res.data.pets.map(pet => pet.name.toUpperCase())
*
- * function(context) {
- * const { res, pet } = context;
+ * function(__bruno__functionInnerContext) {
+ * const { res, pet } = __bruno__functionInnerContext;
* return res.data.pets.map(pet => pet.name.toUpperCase())
* }
* ```
@@ -45,9 +45,11 @@ const compileJsExpression = (expr) => {
globals: globals.map((name) => ` ${name} = ${name} ?? globalThis.${name};`).join('')
};
- const body = `let { ${code.vars} } = context; ${code.globals}; return ${expr}`;
+ // param name that is unlikely to show up as a var in an expression
+ const param = `__bruno__functionInnerContext`;
+ const body = `let { ${code.vars} } = ${param}; ${code.globals}; return ${expr}`;
- return new Function('context', body);
+ return new Function(param, body);
};
const internalExpressionCache = new Map();
@@ -142,6 +144,7 @@ const cleanJson = (data) => {
}
};
+
module.exports = {
evaluateJsExpression,
evaluateJsTemplateLiteral,
diff --git a/packages/bruno-js/src/utils/results.js b/packages/bruno-js/src/utils/results.js
new file mode 100644
index 000000000..0ed38638a
--- /dev/null
+++ b/packages/bruno-js/src/utils/results.js
@@ -0,0 +1,80 @@
+const TestResults = require('../test-results');
+const Test = require('../test');
+
+// Calculate summary statistics for test results
+const getResultsSummary = (results) => {
+ const summary = {
+ total: results.length,
+ passed: 0,
+ failed: 0,
+ skipped: 0,
+ };
+
+ results.forEach((r) => {
+ const passed = r.status === 'pass';
+ if (passed) summary.passed += 1;
+ else if (r.status === 'fail') summary.failed += 1;
+ else summary.skipped += 1;
+ });
+
+ return summary;
+};
+
+const createBruTestResultMethods = (bru, assertionResults, chai) => {
+ const __brunoTestResults = new TestResults();
+ const test = Test(__brunoTestResults, chai);
+ setupBruTestMethods(bru, __brunoTestResults, assertionResults);
+
+ return { __brunoTestResults, test };
+};
+
+const setupBruTestMethods = (bru, __brunoTestResults, assertionResults) => {
+ const getTestResults = async () => {
+ let results = await __brunoTestResults.getResults();
+ const summary = getResultsSummary(results);
+ return {
+ summary,
+ results: results.map(r => ({
+ status: r.status,
+ description: r.description,
+ expected: r.expected,
+ actual: r.actual,
+ error: r.error
+ }))
+ };
+ };
+
+ const getAssertionResults = async () => {
+ let results = assertionResults;
+ const summary = getResultsSummary(results);
+ return {
+ summary,
+ results: results.map(r => ({
+ status: r.status,
+ lhsExpr: r.lhsExpr,
+ rhsExpr: r.rhsExpr,
+ operator: r.operator,
+ rhsOperand: r.rhsOperand,
+ error: r.error
+ }))
+ };
+ };
+
+ // Set methods on bru object if provided
+ if (bru) {
+ bru.getTestResults = getTestResults;
+ bru.getAssertionResults = getAssertionResults;
+ }
+
+ // Also return the methods for direct use
+ return {
+ getTestResults,
+ getAssertionResults
+ };
+};
+
+module.exports = {
+ getResultsSummary,
+ createBruTestResultMethods,
+ setupBruTestMethods
+};
\ No newline at end of file
diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js
index 502cba27b..766569d03 100644
--- a/packages/bruno-js/tests/runtime.spec.js
+++ b/packages/bruno-js/tests/runtime.spec.js
@@ -35,7 +35,7 @@ describe('runtime', () => {
})
`;
- const runtime = new TestRuntime();
+ const runtime = new TestRuntime({ runtime: 'vm2' });
const result = await runtime.runTests(
testFile,
{ ...baseRequest },
@@ -71,7 +71,7 @@ describe('runtime', () => {
})
`;
- const runtime = new TestRuntime();
+ const runtime = new TestRuntime({ runtime: 'vm2' });
const result = await runtime.runTests(
testFile,
{ ...baseRequest },
@@ -114,9 +114,9 @@ describe('runtime', () => {
bru.setVar('validation', validate(new Date().toISOString()))
`;
- const runtime = new ScriptRuntime();
+ const runtime = new ScriptRuntime({ runtime: 'vm2' });
const result = await runtime.runRequestScript(script, { ...baseRequest }, {}, {}, '.', null, process.env);
- expect(result.collectionVariables.validation).toBeTruthy();
+ expect(result.runtimeVariables.validation).toBeTruthy();
});
});
@@ -160,7 +160,7 @@ describe('runtime', () => {
bru.setVar('validation', validate(new Date().toISOString()))
`;
- const runtime = new ScriptRuntime();
+ const runtime = new ScriptRuntime({ runtime: 'vm2' });
const result = await runtime.runResponseScript(
script,
{ ...baseRequest },
@@ -171,7 +171,7 @@ describe('runtime', () => {
null,
process.env
);
- expect(result.collectionVariables.validation).toBeTruthy();
+ expect(result.runtimeVariables.validation).toBeTruthy();
});
});
});
diff --git a/packages/bruno-js/tests/utils.spec.js b/packages/bruno-js/tests/utils.spec.js
index 6ac687f07..b1ecc7db7 100644
--- a/packages/bruno-js/tests/utils.spec.js
+++ b/packages/bruno-js/tests/utils.spec.js
@@ -5,7 +5,9 @@ describe('utils', () => {
describe('expression evaluation', () => {
const context = {
res: {
- data: { pets: ['bruno', 'max'] }
+ data: { pets: ['bruno', 'max'] },
+ context: 'testContext',
+ __bruno__functionInnerContext: 0
}
};
@@ -45,32 +47,32 @@ describe('utils', () => {
it('should identify top level variables', () => {
const expr = 'res.data.pets[0].toUpperCase()';
evaluateJsExpression(expr, context);
- expect(cache.get(expr).toString()).toContain('let { res } = context;');
+ expect(cache.get(expr).toString()).toContain('let { res } = __bruno__functionInnerContext;');
});
it('should not duplicate variables', () => {
const expr = 'res.data.pets[0] + res.data.pets[1]';
evaluateJsExpression(expr, context);
- expect(cache.get(expr).toString()).toContain('let { res } = context;');
+ expect(cache.get(expr).toString()).toContain('let { res } = __bruno__functionInnerContext;');
});
it('should exclude js keywords like true false from vars', () => {
const expr = 'res.data.pets.length > 0 ? true : false';
evaluateJsExpression(expr, context);
- expect(cache.get(expr).toString()).toContain('let { res } = context;');
+ expect(cache.get(expr).toString()).toContain('let { res } = __bruno__functionInnerContext;');
});
it('should exclude numbers from vars', () => {
const expr = 'res.data.pets.length + 10';
evaluateJsExpression(expr, context);
- expect(cache.get(expr).toString()).toContain('let { res } = context;');
+ expect(cache.get(expr).toString()).toContain('let { res } = __bruno__functionInnerContext;');
});
it('should pick variables from complex expressions', () => {
const expr = 'res.data.pets.map(pet => pet.length)';
const result = evaluateJsExpression(expr, context);
expect(result).toEqual([5, 3]);
- expect(cache.get(expr).toString()).toContain('let { res, pet } = context;');
+ expect(cache.get(expr).toString()).toContain('let { res, pet } = __bruno__functionInnerContext;');
});
it('should be ok picking extra vars from strings', () => {
@@ -78,7 +80,7 @@ describe('utils', () => {
const result = evaluateJsExpression(expr, context);
expect(result).toBe('hello bruno');
// extra var hello is harmless
- expect(cache.get(expr).toString()).toContain('let { hello, res } = context;');
+ expect(cache.get(expr).toString()).toContain('let { hello, res } = __bruno__functionInnerContext;');
});
it('should evaluate expressions referencing globals', () => {
@@ -112,6 +114,20 @@ describe('utils', () => {
expect(result).toBe(startTime);
});
+
+ it('should allow "context" as a var name', () => {
+ const expr = 'res["context"].toUpperCase()';
+ evaluateJsExpression(expr, context);
+ expect(cache.get(expr).toString()).toContain('let { res, context } = __bruno__functionInnerContext;');
+ });
+
+ it('should throw an error when we use "__bruno__functionInnerContext" as a var name', () => {
+ const expr = 'res["__bruno__functionInnerContext"].toUpperCase()';
+ expect(() => evaluateJsExpression(expr, context)).toThrow(SyntaxError);
+ expect(() => evaluateJsExpression(expr, context)).toThrow(
+ "Identifier '__bruno__functionInnerContext' has already been declared"
+ );
+ });
});
describe('response parser', () => {
diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js
index 6f12a6ce5..819272240 100644
--- a/packages/bruno-lang/v2/src/bruToJson.js
+++ b/packages/bruno-lang/v2/src/bruToJson.js
@@ -1,6 +1,6 @@
const ohm = require('ohm-js');
const _ = require('lodash');
-const { outdentString } = require('../../v1/src/utils');
+const { safeParseJson, outdentString } = require('./utils');
/**
* A Bru file is made up of blocks.
@@ -22,10 +22,11 @@ const { outdentString } = require('../../v1/src/utils');
*
*/
const grammar = ohm.grammar(`Bru {
- BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
- auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
+ BruFile = (meta | http | query | params | headers | auths | bodies | varsandassert | script | tests | docs)*
+ auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth2 | authwsse | authapikey
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
- bodyforms = bodyformurlencoded | bodymultipart
+ bodyforms = bodyformurlencoded | bodymultipart | bodyfile
+ params = paramspath | paramsquery
nl = "\\r"? "\\n"
st = " " | "\\t"
@@ -45,7 +46,7 @@ const grammar = ohm.grammar(`Bru {
pair = st* key st* ":" st* value st*
key = keychar*
value = multilinetextblock | valuechar*
-
+
// Dictionary for Assert Block
assertdictionary = st* "{" assertpairlist? tagend
assertpairlist = optionalnl* assertpair (~tagend stnl* assertpair)* (~tagend space)*
@@ -74,6 +75,8 @@ const grammar = ohm.grammar(`Bru {
headers = "headers" dictionary
query = "query" dictionary
+ paramspath = "params:path" dictionary
+ paramsquery = "params:query" dictionary
varsandassert = varsreq | varsres | assert
varsreq = "vars:pre-request" dictionary
@@ -84,7 +87,10 @@ const grammar = ohm.grammar(`Bru {
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
+ authNTLM = "auth:ntlm" dictionary
authOAuth2 = "auth:oauth2" dictionary
+ authwsse = "auth:wsse" dictionary
+ authapikey = "auth:apikey" dictionary
body = "body" st* "{" nl* textblock tagend
bodyjson = "body:json" st* "{" nl* textblock tagend
@@ -96,7 +102,8 @@ const grammar = ohm.grammar(`Bru {
bodyformurlencoded = "body:form-urlencoded" dictionary
bodymultipart = "body:multipart-form" dictionary
-
+ bodyfile = "body:file" dictionary
+
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
scriptres = "script:post-response" st* "{" nl* textblock tagend
@@ -133,16 +140,87 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => {
});
};
+const mapRequestParams = (pairList = [], type) => {
+ if (!pairList.length) {
+ return [];
+ }
+ return _.map(pairList[0], (pair) => {
+ let name = _.keys(pair)[0];
+ let value = pair[name];
+ let enabled = true;
+ if (name && name.length && name.charAt(0) === '~') {
+ name = name.slice(1);
+ enabled = false;
+ }
+
+ return {
+ name,
+ value,
+ enabled,
+ type
+ };
+ });
+};
+
+const multipartExtractContentType = (pair) => {
+ if (_.isString(pair.value)) {
+ const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
+ if (match != null && match.length > 2) {
+ pair.value = match[1];
+ pair.contentType = match[2];
+ } else {
+ pair.contentType = '';
+ }
+ }
+};
+
+const fileExtractContentType = (pair) => {
+ if (_.isString(pair.value)) {
+ const match = pair.value.match(/^(.*?)\s*@contentType\((.*?)\)\s*$/);
+ if (match && match.length > 2) {
+ pair.value = match[1].trim();
+ pair.contentType = match[2].trim();
+ } else {
+ pair.contentType = '';
+ }
+ }
+};
+
+
const mapPairListToKeyValPairsMultipart = (pairList = [], parseEnabled = true) => {
const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
return pairs.map((pair) => {
pair.type = 'text';
+ multipartExtractContentType(pair);
+
if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
let filestr = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
pair.type = 'file';
pair.value = filestr.split('|');
}
+
+ return pair;
+ });
+};
+
+const mapPairListToKeyValPairsFile = (pairList = [], parseEnabled = true) => {
+ const pairs = mapPairListToKeyValPairs(pairList, parseEnabled);
+ return pairs.map((pair) => {
+ fileExtractContentType(pair);
+
+ if (pair.value.startsWith('@file(') && pair.value.endsWith(')')) {
+ let filePath = pair.value.replace(/^@file\(/, '').replace(/\)$/, '');
+ pair.filePath = filePath;
+ pair.selected = pair.enabled
+
+ // Remove pair.value as it only contains the file path reference
+ delete pair.value;
+ // Remove pair.name as it is auto-generated (e.g., file1, file2, file3, etc.)
+ delete pair.name;
+ delete pair.enabled;
+ }
+
return pair;
});
};
@@ -321,7 +399,17 @@ const sem = grammar.createSemantics().addAttribute('ast', {
},
query(_1, dictionary) {
return {
- query: mapPairListToKeyValPairs(dictionary.ast)
+ params: mapRequestParams(dictionary.ast, 'query')
+ };
+ },
+ paramspath(_1, dictionary) {
+ return {
+ params: mapRequestParams(dictionary.ast, 'path')
+ };
+ },
+ paramsquery(_1, dictionary) {
+ return {
+ params: mapRequestParams(dictionary.ast, 'query')
};
},
headers(_1, dictionary) {
@@ -398,6 +486,26 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ authNTLM(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+ const usernameKey = _.find(auth, { name: 'username' });
+ const passwordKey = _.find(auth, { name: 'password' });
+ const domainKey = _.find(auth, { name: 'domain' });
+
+ const username = usernameKey ? usernameKey.value : '';
+ const password = passwordKey ? passwordKey.value : '';
+ const domain = passwordKey ? domainKey.value : '';
+
+ return {
+ auth: {
+ ntlm: {
+ username,
+ password,
+ domain
+ }
+ }
+ };
+ },
authOAuth2(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const grantTypeKey = _.find(auth, { name: 'grant_type' });
@@ -406,10 +514,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
+ const refreshTokenUrlKey = _.find(auth, { name: 'refresh_token_url' });
const clientIdKey = _.find(auth, { name: 'client_id' });
const clientSecretKey = _.find(auth, { name: 'client_secret' });
const scopeKey = _.find(auth, { name: 'scope' });
+ const stateKey = _.find(auth, { name: 'state' });
const pkceKey = _.find(auth, { name: 'pkce' });
+ const credentialsPlacementKey = _.find(auth, { name: 'credentials_placement' });
+ const credentialsIdKey = _.find(auth, { name: 'credentials_id' });
+ const tokenPlacementKey = _.find(auth, { name: 'token_placement' });
+ const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' });
+ const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' });
+ const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' });
+ const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' });
return {
auth: {
oauth2:
@@ -417,11 +534,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
username: usernameKey ? usernameKey.value : '',
password: passwordKey ? passwordKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
- scope: scopeKey ? scopeKey.value : ''
+ scope: scopeKey ? scopeKey.value : '',
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'authorization_code'
? {
@@ -429,23 +554,79 @@ const sem = grammar.createSemantics().addAttribute('ast', {
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : '',
- pkce: pkceKey ? JSON.parse(pkceKey?.value || false) : false
+ state: stateKey ? stateKey.value : '',
+ pkce: pkceKey ? safeParseJson(pkceKey?.value) ?? false : false,
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'client_credentials'
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
- scope: scopeKey ? scopeKey.value : ''
+ scope: scopeKey ? scopeKey.value : '',
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: {}
}
};
},
+ authwsse(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+
+ const userKey = _.find(auth, { name: 'username' });
+ const secretKey = _.find(auth, { name: 'password' });
+ const username = userKey ? userKey.value : '';
+ const password = secretKey ? secretKey.value : '';
+
+ return {
+ auth: {
+ wsse: {
+ username,
+ password
+ }
+ }
+ };
+ },
+ authapikey(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+
+ const findValueByName = (name) => {
+ const item = _.find(auth, { name });
+ return item ? item.value : '';
+ };
+
+ const key = findValueByName('key');
+ const value = findValueByName('value');
+ const placement = findValueByName('placement');
+
+ return {
+ auth: {
+ apikey: {
+ key,
+ value,
+ placement
+ }
+ }
+ };
+ },
bodyformurlencoded(_1, dictionary) {
return {
body: {
@@ -460,6 +641,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ bodyfile(_1, dictionary) {
+ return {
+ body: {
+ file: mapPairListToKeyValPairsFile(dictionary.ast)
+ }
+ };
+ },
body(_1, _2, _3, _4, textblock, _5) {
return {
http: {
@@ -594,3 +782,4 @@ const parser = (input) => {
};
module.exports = parser;
+
\ No newline at end of file
diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js
index 355f2f966..e92dcaa88 100644
--- a/packages/bruno-lang/v2/src/collectionBruToJson.js
+++ b/packages/bruno-lang/v2/src/collectionBruToJson.js
@@ -1,10 +1,10 @@
const ohm = require('ohm-js');
const _ = require('lodash');
-const { outdentString } = require('../../v1/src/utils');
+const { safeParseJson, outdentString } = require('./utils');
const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
- auths = authawsv4 | authbasic | authbearer | authdigest | authOAuth2
+ auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM |authOAuth2 | authwsse | authapikey
nl = "\\r"? "\\n"
st = " " | "\\t"
@@ -42,7 +42,10 @@ const grammar = ohm.grammar(`Bru {
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
+ authNTLM = "auth:ntlm" dictionary
authOAuth2 = "auth:oauth2" dictionary
+ authwsse = "auth:wsse" dictionary
+ authapikey = "auth:apikey" dictionary
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
@@ -160,7 +163,7 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return {
auth: {
- mode: auth ? auth.mode : 'none'
+ mode: auth?.mode || 'none'
}
};
},
@@ -243,6 +246,26 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ authNTLM(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+ const usernameKey = _.find(auth, { name: 'username' });
+ const passwordKey = _.find(auth, { name: 'password' });
+ const domainKey = _.find(auth, { name: 'domain' });
+
+ const username = usernameKey ? usernameKey.value : '';
+ const password = passwordKey ? passwordKey.value : '';
+ const domain = domainKey ? domainKey.value : '';
+
+ return {
+ auth: {
+ ntlm: {
+ username,
+ password,
+ domain
+ }
+ }
+ };
+ },
authOAuth2(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const grantTypeKey = _.find(auth, { name: 'grant_type' });
@@ -251,10 +274,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
const callbackUrlKey = _.find(auth, { name: 'callback_url' });
const authorizationUrlKey = _.find(auth, { name: 'authorization_url' });
const accessTokenUrlKey = _.find(auth, { name: 'access_token_url' });
+ const refreshTokenUrlKey = _.find(auth, { name: 'refresh_token_url' });
const clientIdKey = _.find(auth, { name: 'client_id' });
const clientSecretKey = _.find(auth, { name: 'client_secret' });
const scopeKey = _.find(auth, { name: 'scope' });
+ const stateKey = _.find(auth, { name: 'state' });
const pkceKey = _.find(auth, { name: 'pkce' });
+ const credentialsPlacementKey = _.find(auth, { name: 'credentials_placement' });
+ const credentialsIdKey = _.find(auth, { name: 'credentials_id' });
+ const tokenPlacementKey = _.find(auth, { name: 'token_placement' });
+ const tokenHeaderPrefixKey = _.find(auth, { name: 'token_header_prefix' });
+ const tokenQueryKeyKey = _.find(auth, { name: 'token_query_key' });
+ const autoFetchTokenKey = _.find(auth, { name: 'auto_fetch_token' });
+ const autoRefreshTokenKey = _.find(auth, { name: 'auto_refresh_token' });
return {
auth: {
oauth2:
@@ -262,11 +294,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
username: usernameKey ? usernameKey.value : '',
password: passwordKey ? passwordKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
- scope: scopeKey ? scopeKey.value : ''
+ scope: scopeKey ? scopeKey.value : '',
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'authorization_code'
? {
@@ -274,23 +314,77 @@ const sem = grammar.createSemantics().addAttribute('ast', {
callbackUrl: callbackUrlKey ? callbackUrlKey.value : '',
authorizationUrl: authorizationUrlKey ? authorizationUrlKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
scope: scopeKey ? scopeKey.value : '',
- pkce: pkceKey ? JSON.parse(pkceKey?.value || false) : false
+ state: stateKey ? stateKey.value : '',
+ pkce: pkceKey ? safeParseJson(pkceKey?.value) ?? false : false,
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: grantTypeKey?.value && grantTypeKey?.value == 'client_credentials'
? {
grantType: grantTypeKey ? grantTypeKey.value : '',
accessTokenUrl: accessTokenUrlKey ? accessTokenUrlKey.value : '',
+ refreshTokenUrl: refreshTokenUrlKey ? refreshTokenUrlKey.value : '',
clientId: clientIdKey ? clientIdKey.value : '',
clientSecret: clientSecretKey ? clientSecretKey.value : '',
- scope: scopeKey ? scopeKey.value : ''
+ scope: scopeKey ? scopeKey.value : '',
+ credentialsPlacement: credentialsPlacementKey?.value ? credentialsPlacementKey.value : 'body',
+ credentialsId: credentialsIdKey?.value ? credentialsIdKey.value : 'credentials',
+ tokenPlacement: tokenPlacementKey?.value ? tokenPlacementKey.value : 'header',
+ tokenHeaderPrefix: tokenHeaderPrefixKey?.value ? tokenHeaderPrefixKey.value : 'Bearer',
+ tokenQueryKey: tokenQueryKeyKey?.value ? tokenQueryKeyKey.value : 'access_token',
+ autoFetchToken: autoFetchTokenKey ? safeParseJson(autoFetchTokenKey?.value) ?? true : true,
+ autoRefreshToken: autoRefreshTokenKey ? safeParseJson(autoRefreshTokenKey?.value) ?? false : false
}
: {}
}
};
},
+ authwsse(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+ const userKey = _.find(auth, { name: 'username' });
+ const secretKey = _.find(auth, { name: 'password' });
+ const username = userKey ? userKey.value : '';
+ const password = secretKey ? secretKey.value : '';
+ return {
+ auth: {
+ wsse: {
+ username,
+ password
+ }
+ }
+ }
+ },
+ authapikey(_1, dictionary) {
+ const auth = mapPairListToKeyValPairs(dictionary.ast, false);
+
+ const findValueByName = (name) => {
+ const item = _.find(auth, { name });
+ return item ? item.value : '';
+ };
+
+ const key = findValueByName('key');
+ const value = findValueByName('value');
+ const placement = findValueByName('placement');
+
+ return {
+ auth: {
+ apikey: {
+ key,
+ value,
+ placement
+ }
+ }
+ };
+ },
varsreq(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
_.each(vars, (v) => {
diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js
index eef4de375..e88c593c1 100644
--- a/packages/bruno-lang/v2/src/envToJson.js
+++ b/packages/bruno-lang/v2/src/envToJson.js
@@ -68,7 +68,7 @@ const mapArrayListToKeyValPairs = (arrayList = []) => {
return {
name,
- value: null,
+ value: '',
enabled
};
});
diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js
index 3357e5d09..c7395e2ff 100644
--- a/packages/bruno-lang/v2/src/jsonToBru.js
+++ b/packages/bruno-lang/v2/src/jsonToBru.js
@@ -1,9 +1,9 @@
const _ = require('lodash');
-const { indentString } = require('../../v1/src/utils');
+const { indentString } = require('./utils');
-const enabled = (items = []) => items.filter((item) => item.enabled);
-const disabled = (items = []) => items.filter((item) => !item.enabled);
+const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]);
+const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]);
// remove the last line if two new lines are found
const stripLastLine = (text) => {
@@ -13,7 +13,7 @@ const stripLastLine = (text) => {
};
const getValueString = (value) => {
- const hasNewLines = value.includes('\n');
+ const hasNewLines = value?.includes('\n');
if (!hasNewLines) {
return value;
@@ -30,7 +30,7 @@ const getValueString = (value) => {
};
const jsonToBru = (json) => {
- const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json;
+ const { meta, http, params, headers, auth, body, script, tests, vars, assertions, docs } = json;
let bru = '';
@@ -62,25 +62,38 @@ const jsonToBru = (json) => {
`;
}
- if (query && query.length) {
- bru += 'query {';
- if (enabled(query).length) {
- bru += `\n${indentString(
- enabled(query)
- .map((item) => `${item.name}: ${item.value}`)
- .join('\n')
- )}`;
+ if (params && params.length) {
+ const queryParams = params.filter((param) => param.type === 'query');
+ const pathParams = params.filter((param) => param.type === 'path');
+
+ if (queryParams.length) {
+ bru += 'params:query {';
+ if (enabled(queryParams).length) {
+ bru += `\n${indentString(
+ enabled(queryParams)
+ .map((item) => `${item.name}: ${item.value}`)
+ .join('\n')
+ )}`;
+ }
+
+ if (disabled(queryParams).length) {
+ bru += `\n${indentString(
+ disabled(queryParams)
+ .map((item) => `~${item.name}: ${item.value}`)
+ .join('\n')
+ )}`;
+ }
+
+ bru += '\n}\n\n';
}
- if (disabled(query).length) {
- bru += `\n${indentString(
- disabled(query)
- .map((item) => `~${item.name}: ${item.value}`)
- .join('\n')
- )}`;
- }
+ if (pathParams.length) {
+ bru += 'params:path {';
- bru += '\n}\n\n';
+ bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`;
+
+ bru += '\n}\n\n';
+ }
}
if (headers && headers.length) {
@@ -123,6 +136,15 @@ ${indentString(`username: ${auth?.basic?.username || ''}`)}
${indentString(`password: ${auth?.basic?.password || ''}`)}
}
+`;
+ }
+
+ if (auth && auth.wsse) {
+ bru += `auth:wsse {
+${indentString(`username: ${auth?.wsse?.username || ''}`)}
+${indentString(`password: ${auth?.wsse?.password || ''}`)}
+}
+
`;
}
@@ -143,17 +165,39 @@ ${indentString(`password: ${auth?.digest?.password || ''}`)}
`;
}
+
+ if (auth && auth.ntlm) {
+ bru += `auth:ntlm {
+${indentString(`username: ${auth?.ntlm?.username || ''}`)}
+${indentString(`password: ${auth?.ntlm?.password || ''}`)}
+${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
+
+}
+
+`;
+ }
+
if (auth && auth.oauth2) {
switch (auth?.oauth2?.grantType) {
case 'password':
bru += `auth:oauth2 {
${indentString(`grant_type: password`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -164,10 +208,21 @@ ${indentString(`grant_type: authorization_code`)}
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`state: ${auth?.oauth2?.state || ''}`)}
${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -176,9 +231,19 @@ ${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: client_credentials`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -186,6 +251,16 @@ ${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
}
}
+ if (auth && auth.apikey) {
+ bru += `auth:apikey {
+${indentString(`key: ${auth?.apikey?.key || ''}`)}
+${indentString(`value: ${auth?.apikey?.value || ''}`)}
+${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
+}
+
+`;
+ }
+
if (body && body.json && body.json.length) {
bru += `body:json {
${indentString(body.json)}
@@ -247,16 +322,19 @@ ${indentString(body.sparql)}
multipartForms
.map((item) => {
const enabled = item.enabled ? '' : '~';
+ const contentType =
+ item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'text') {
- return `${enabled}${item.name}: ${item.value}`;
+ return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`;
}
if (item.type === 'file') {
- let filepaths = item.value || [];
- let filestr = filepaths.join('|');
+ const filepaths = Array.isArray(item.value) ? item.value : [];
+ const filestr = filepaths.join('|');
+
const value = `@file(${filestr})`;
- return `${enabled}${item.name}: ${value}`;
+ return `${enabled}${item.name}: ${value}${contentType}`;
}
})
.join('\n')
@@ -266,6 +344,30 @@ ${indentString(body.sparql)}
bru += '\n}\n\n';
}
+
+ if (body && body.file && body.file.length) {
+ bru += `body:file {`;
+ const files = enabled(body.file, "selected").concat(disabled(body.file, "selected"));
+
+ if (files.length) {
+ bru += `\n${indentString(
+ files
+ .map((item) => {
+ const selected = item.selected ? '' : '~';
+ const contentType =
+ item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
+ const filePath = item.filePath || '';
+ const value = `@file(${filePath})`;
+ const itemName = "file";
+ return `${selected}${itemName}: ${value}${contentType}`;
+ })
+ .join('\n')
+ )}`;
+ }
+
+ bru += '\n}\n\n';
+ }
+
if (body && body.graphql && body.graphql.query) {
bru += `body:graphql {\n`;
bru += `${indentString(body.graphql.query)}`;
@@ -392,4 +494,4 @@ ${indentString(docs)}
module.exports = jsonToBru;
-// alternative to writing the below code to avoif undefined
+// alternative to writing the below code to avoid undefined
diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js
index e4d6ab5fd..2812798a5 100644
--- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js
+++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js
@@ -1,6 +1,6 @@
const _ = require('lodash');
-const { indentString } = require('../../v1/src/utils');
+const { indentString } = require('./utils');
const enabled = (items = []) => items.filter((item) => item.enabled);
const disabled = (items = []) => items.filter((item) => !item.enabled);
@@ -94,6 +94,15 @@ ${indentString(`username: ${auth.basic.username}`)}
${indentString(`password: ${auth.basic.password}`)}
}
+`;
+ }
+
+ if (auth && auth.wsse) {
+ bru += `auth:wsse {
+${indentString(`username: ${auth.wsse.username}`)}
+${indentString(`password: ${auth.wsse.password}`)}
+}
+
`;
}
@@ -111,6 +120,26 @@ ${indentString(`username: ${auth.digest.username}`)}
${indentString(`password: ${auth.digest.password}`)}
}
+`;
+ }
+
+if (auth && auth.ntlm) {
+ bru += `auth:ntlm {
+${indentString(`username: ${auth.ntlm.username}`)}
+${indentString(`password: ${auth.ntlm.password}`)}
+${indentString(`domain: ${auth.ntlm.domain}`)}
+
+}
+
+`;
+ }
+
+ if (auth && auth.apikey) {
+ bru += `auth:apikey {
+${indentString(`key: ${auth?.apikey?.key || ''}`)}
+${indentString(`value: ${auth?.apikey?.value || ''}`)}
+${indentString(`placement: ${auth?.apikey?.placement || ''}`)}
+}
`;
}
@@ -120,11 +149,21 @@ ${indentString(`password: ${auth.digest.password}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: password`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`username: ${auth?.oauth2?.username || ''}`)}
${indentString(`password: ${auth?.oauth2?.password || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -135,10 +174,21 @@ ${indentString(`grant_type: authorization_code`)}
${indentString(`callback_url: ${auth?.oauth2?.callbackUrl || ''}`)}
${indentString(`authorization_url: ${auth?.oauth2?.authorizationUrl || ''}`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`state: ${auth?.oauth2?.state || ''}`)}
${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
@@ -147,9 +197,19 @@ ${indentString(`pkce: ${(auth?.oauth2?.pkce || false).toString()}`)}
bru += `auth:oauth2 {
${indentString(`grant_type: client_credentials`)}
${indentString(`access_token_url: ${auth?.oauth2?.accessTokenUrl || ''}`)}
+${indentString(`refresh_token_url: ${auth?.oauth2?.refreshTokenUrl || ''}`)}
${indentString(`client_id: ${auth?.oauth2?.clientId || ''}`)}
${indentString(`client_secret: ${auth?.oauth2?.clientSecret || ''}`)}
${indentString(`scope: ${auth?.oauth2?.scope || ''}`)}
+${indentString(`credentials_placement: ${auth?.oauth2?.credentialsPlacement || ''}`)}
+${indentString(`credentials_id: ${auth?.oauth2?.credentialsId || ''}`)}
+${indentString(`token_placement: ${auth?.oauth2?.tokenPlacement || ''}`)}${
+ auth?.oauth2?.tokenPlacement == 'header' ? '\n' + indentString(`token_header_prefix: ${auth?.oauth2?.tokenHeaderPrefix || ''}`) : ''
+}${
+ auth?.oauth2?.tokenPlacement !== 'header' ? '\n' + indentString(`token_query_key: ${auth?.oauth2?.tokenQueryKey || ''}`) : ''
+}
+${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toString()}`)}
+${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false).toString()}`)}
}
`;
diff --git a/packages/bruno-lang/v2/src/utils.js b/packages/bruno-lang/v2/src/utils.js
new file mode 100644
index 000000000..74b22c952
--- /dev/null
+++ b/packages/bruno-lang/v2/src/utils.js
@@ -0,0 +1,36 @@
+// safely parse json
+const safeParseJson = (json) => {
+ try {
+ return JSON.parse(json);
+ } catch (e) {
+ return null;
+ }
+};
+
+const indentString = (str) => {
+ if (!str || !str.length) {
+ return str || '';
+ }
+
+ return str
+ .split('\n')
+ .map((line) => ' ' + line)
+ .join('\n');
+};
+
+const outdentString = (str) => {
+ if (!str || !str.length) {
+ return str || '';
+ }
+
+ return str
+ .split('\n')
+ .map((line) => line.replace(/^ /, ''))
+ .join('\n');
+};
+
+module.exports = {
+ safeParseJson,
+ indentString,
+ outdentString
+};
diff --git a/packages/bruno-lang/v2/tests/envToJson.spec.js b/packages/bruno-lang/v2/tests/envToJson.spec.js
index fbb74f2b9..9c97cd1fa 100644
--- a/packages/bruno-lang/v2/tests/envToJson.spec.js
+++ b/packages/bruno-lang/v2/tests/envToJson.spec.js
@@ -185,7 +185,7 @@ vars:secret [
},
{
name: 'token',
- value: null,
+ value: '',
enabled: true,
secret: true
}
@@ -220,19 +220,19 @@ vars:secret [
},
{
name: 'access_token',
- value: null,
+ value: '',
enabled: true,
secret: true
},
{
name: 'access_secret',
- value: null,
+ value: '',
enabled: true,
secret: true
},
{
name: 'access_password',
- value: null,
+ value: '',
enabled: false,
secret: true
}
@@ -262,7 +262,7 @@ vars:secret [access_key]
},
{
name: 'access_key',
- value: null,
+ value: '',
enabled: true,
secret: true
}
@@ -292,19 +292,19 @@ vars:secret [access_key,access_secret, access_password ]
},
{
name: 'access_key',
- value: null,
+ value: '',
enabled: true,
secret: true
},
{
name: 'access_secret',
- value: null,
+ value: '',
enabled: true,
secret: true
},
{
name: 'access_password',
- value: null,
+ value: '',
enabled: true,
secret: true
}
diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.bru b/packages/bruno-lang/v2/tests/fixtures/collection.bru
index 44a66c8dc..f11954ebf 100644
--- a/packages/bruno-lang/v2/tests/fixtures/collection.bru
+++ b/packages/bruno-lang/v2/tests/fixtures/collection.bru
@@ -17,6 +17,11 @@ auth:basic {
password: secret
}
+auth:wsse {
+ username: john
+ password: secret
+}
+
auth:bearer {
token: 123
}
diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.json b/packages/bruno-lang/v2/tests/fixtures/collection.json
index 7bda2534d..102ee295c 100644
--- a/packages/bruno-lang/v2/tests/fixtures/collection.json
+++ b/packages/bruno-lang/v2/tests/fixtures/collection.json
@@ -31,6 +31,10 @@
"digest": {
"username": "john",
"password": "secret"
+ },
+ "wsse": {
+ "username": "john",
+ "password": "secret"
}
},
"vars": {
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru
index fcfe7b818..c3f81b780 100644
--- a/packages/bruno-lang/v2/tests/fixtures/request.bru
+++ b/packages/bruno-lang/v2/tests/fixtures/request.bru
@@ -5,17 +5,21 @@ meta {
}
get {
- url: https://api.textlocal.in/send
+ url: https://api.textlocal.in/send/:id
body: json
auth: bearer
}
-query {
+params:query {
apiKey: secret
numbers: 998877665
~message: hello
}
+params:path {
+ id: 123
+}
+
headers {
content-type: application/json
Authorization: Bearer 123
@@ -36,6 +40,11 @@ auth:basic {
password: secret
}
+auth:wsse {
+ username: john
+ password: secret
+}
+
auth:bearer {
token: 123
}
@@ -50,10 +59,18 @@ auth:oauth2 {
callback_url: http://localhost:8080/api/auth/oauth2/authorization_code/callback
authorization_url: http://localhost:8080/api/auth/oauth2/authorization_code/authorize
access_token_url: http://localhost:8080/api/auth/oauth2/authorization_code/token
+ refresh_token_url:
client_id: client_id_1
client_secret: client_secret_1
scope: read write
+ state: 807061d5f0be
pkce: false
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: true
}
body:json {
@@ -92,6 +109,12 @@ body:multipart-form {
~message: hello
}
+body:file {
+ file: @file(path/to/file.json) @contentType(application/json)
+ file: @file(path/to/file.json) @contentType(application/json)
+ ~file: @file(path/to/file2.json) @contentType(application/json)
+}
+
body:graphql {
{
launchesPast {
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json
index afb7ca3f9..5cdebec00 100644
--- a/packages/bruno-lang/v2/tests/fixtures/request.json
+++ b/packages/bruno-lang/v2/tests/fixtures/request.json
@@ -6,25 +6,34 @@
},
"http": {
"method": "get",
- "url": "https://api.textlocal.in/send",
+ "url": "https://api.textlocal.in/send/:id",
"body": "json",
"auth": "bearer"
},
- "query": [
+ "params": [
{
"name": "apiKey",
"value": "secret",
+ "type": "query",
"enabled": true
},
{
"name": "numbers",
"value": "998877665",
+ "type": "query",
"enabled": true
},
{
"name": "message",
"value": "hello",
+ "type": "query",
"enabled": false
+ },
+ {
+ "name": "id",
+ "value": "123",
+ "type": "path",
+ "enabled": true
}
],
"headers": [
@@ -65,14 +74,27 @@
"password": "secret"
},
"oauth2": {
- "grantType": "authorization_code",
+ "accessTokenUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/token",
+ "authorizationUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/authorize",
+ "autoFetchToken": true,
+ "autoRefreshToken": true,
+ "callbackUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/callback",
"clientId": "client_id_1",
"clientSecret": "client_secret_1",
- "authorizationUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/authorize",
- "callbackUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/callback",
- "accessTokenUrl": "http://localhost:8080/api/auth/oauth2/authorization_code/token",
+ "credentialsId": "credentials",
+ "credentialsPlacement": "body",
+ "grantType": "authorization_code",
+ "pkce": false,
+ "refreshTokenUrl": "",
"scope": "read write",
- "pkce": false
+ "state": "807061d5f0be",
+ "tokenHeaderPrefix": "Bearer",
+ "tokenPlacement": "header",
+ "tokenQueryKey": "access_token"
+ },
+ "wsse": {
+ "username": "john",
+ "password": "secret"
}
},
"body": {
@@ -103,23 +125,43 @@
],
"multipartForm": [
{
+ "contentType": "",
"name": "apikey",
"value": "secret",
"enabled": true,
"type": "text"
},
{
+ "contentType": "",
"name": "numbers",
"value": "+91998877665",
"enabled": true,
"type": "text"
},
{
+ "contentType": "",
"name": "message",
"value": "hello",
"enabled": false,
"type": "text"
}
+ ],
+ "file" : [
+ {
+ "filePath": "path/to/file.json",
+ "contentType": "application/json",
+ "selected": true
+ },
+ {
+ "filePath": "path/to/file.json",
+ "contentType": "application/json",
+ "selected": true
+ },
+ {
+ "filePath": "path/to/file2.json",
+ "contentType": "application/json",
+ "selected": false
+ }
]
},
"vars": {
diff --git a/packages/bruno-query/package.json b/packages/bruno-query/package.json
index 140fdeafe..fda74c693 100644
--- a/packages/bruno-query/package.json
+++ b/packages/bruno-query/package.json
@@ -15,19 +15,20 @@
"test": "jest",
"prebuild": "npm run clean",
"build": "rollup -c",
+ "watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
- "rollup": "3.2.5",
+ "rollup":"3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
- "rollup": "3.2.5"
+ "rollup":"3.29.5"
}
}
\ No newline at end of file
diff --git a/packages/bruno-query/readme.md b/packages/bruno-query/readme.md
index 37801f52b..121d8f33b 100644
--- a/packages/bruno-query/readme.md
+++ b/packages/bruno-query/readme.md
@@ -24,7 +24,7 @@ get(data, '..items[?]', { id: 2, amount: 20 })
```
Array mapping [?] with corresponding mapper function
```js
-get(data, '..items[?].amount', i => i.amount + 10)
+get(data, '..items..amount[?]', amt => amt + 10)
```
### Publish to Npm Registry
diff --git a/packages/bruno-requests/.gitignore b/packages/bruno-requests/.gitignore
new file mode 100644
index 000000000..f6eabff32
--- /dev/null
+++ b/packages/bruno-requests/.gitignore
@@ -0,0 +1,22 @@
+# dependencies
+node_modules
+yarn.lock
+pnpm-lock.yaml
+package-lock.json
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# production
+dist
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json
new file mode 100644
index 000000000..1aed11446
--- /dev/null
+++ b/packages/bruno-requests/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@usebruno/requests",
+ "version": "0.1.0",
+ "license": "MIT",
+ "main": "dist/cjs/index.js",
+ "module": "dist/esm/index.js",
+ "types": "dist/index.d.js",
+ "files": [
+ "dist",
+ "src",
+ "package.json"
+ ],
+ "scripts": {
+ "clean": "rimraf dist",
+ "prebuild": "npm run clean",
+ "build": "rollup -c",
+ "watch": "rollup -c -w",
+ "prepack": "npm run test && npm run build"
+ },
+ "devDependencies": {
+ "@rollup/plugin-commonjs": "^23.0.2",
+ "@rollup/plugin-node-resolve": "^15.0.1",
+ "@rollup/plugin-typescript": "^9.0.2",
+ "rollup": "3.29.5",
+ "rollup-plugin-dts": "^5.0.0",
+ "rollup-plugin-peer-deps-external": "^2.2.4",
+ "rollup-plugin-terser": "^7.0.2",
+ "typescript": "^4.8.4"
+ },
+ "overrides": {
+ "rollup": "3.29.5"
+ },
+ "dependencies": {
+ "@types/qs": "^6.9.18"
+ }
+}
diff --git a/packages/bruno-requests/rollup.config.js b/packages/bruno-requests/rollup.config.js
new file mode 100644
index 000000000..83422321d
--- /dev/null
+++ b/packages/bruno-requests/rollup.config.js
@@ -0,0 +1,38 @@
+const { nodeResolve } = require('@rollup/plugin-node-resolve');
+const commonjs = require('@rollup/plugin-commonjs');
+const typescript = require('@rollup/plugin-typescript');
+const dts = require('rollup-plugin-dts');
+const { terser } = require('rollup-plugin-terser');
+const peerDepsExternal = require('rollup-plugin-peer-deps-external');
+
+const packageJson = require('./package.json');
+
+module.exports = [
+ {
+ input: 'src/index.ts',
+ output: [
+ {
+ file: packageJson.main,
+ format: 'cjs',
+ sourcemap: true,
+ exports: 'named'
+ },
+ {
+ file: packageJson.module,
+ format: 'esm',
+ sourcemap: true,
+ exports: 'named'
+ }
+ ],
+ plugins: [
+ peerDepsExternal(),
+ nodeResolve({
+ extensions: ['.js', '.ts', '.tsx', '.json', '.css']
+ }),
+ commonjs(),
+ typescript({ tsconfig: './tsconfig.json' }),
+ terser(),
+ ],
+ external: ['axios', 'qs']
+ }
+];
diff --git a/packages/bruno-requests/src/auth/digestauth-helper.js b/packages/bruno-requests/src/auth/digestauth-helper.js
new file mode 100644
index 000000000..25911a6b3
--- /dev/null
+++ b/packages/bruno-requests/src/auth/digestauth-helper.js
@@ -0,0 +1,124 @@
+const crypto = require('crypto');
+const { URL } = require('url');
+
+function isStrPresent(str) {
+ return str && str.trim() !== '' && str.trim() !== 'undefined';
+}
+
+function stripQuotes(str) {
+ return str.replace(/"/g, '');
+}
+
+function containsDigestHeader(response) {
+ const authHeader = response?.headers?.['www-authenticate'];
+ return authHeader ? authHeader.trim().toLowerCase().startsWith('digest') : false;
+}
+
+function containsAuthorizationHeader(originalRequest) {
+ return Boolean(
+ originalRequest.headers['Authorization'] ||
+ originalRequest.headers['authorization']
+ );
+}
+
+function md5(input) {
+ return crypto.createHash('md5').update(input).digest('hex');
+}
+
+export function addDigestInterceptor(axiosInstance, request) {
+ const { username, password } = request.digestConfig;
+ console.debug('Digest Auth Interceptor Initialized');
+
+ if (!isStrPresent(username) || !isStrPresent(password)) {
+ console.warn('Required Digest Auth fields (username/password) are not present');
+ return;
+ }
+
+ axiosInstance.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ const originalRequest = error.config;
+
+ // Prevent retry loops
+ if (originalRequest._retry) {
+ return Promise.reject(error);
+ }
+ originalRequest._retry = true;
+
+ if (
+ error.response?.status === 401 &&
+ containsDigestHeader(error.response) &&
+ !containsAuthorizationHeader(originalRequest)
+ ) {
+ console.debug('Processing Digest Authentication Challenge');
+ console.debug(error.response.headers['www-authenticate']);
+
+ const authDetails = error.response.headers['www-authenticate']
+ .split(',')
+ .map((pair) => pair.split('=').map((item) => item.trim()).map(stripQuotes))
+ .reduce((acc, [key, value]) => {
+ const normalizedKey = key.toLowerCase().replace('digest ', '');
+ if (normalizedKey && value !== undefined) {
+ acc[normalizedKey] = value;
+ }
+ return acc;
+ }, {});
+
+ // Validate required auth details
+ if (!authDetails.realm || !authDetails.nonce) {
+ console.warn('Missing required auth details (realm or nonce)');
+ return Promise.reject(error);
+ }
+
+ console.debug("Auth Details: \n", authDetails);
+
+ const nonceCount = '00000001';
+ const cnonce = crypto.randomBytes(24).toString('hex');
+
+ if (authDetails.algorithm && authDetails.algorithm.toUpperCase() !== 'MD5') {
+ console.warn(`Unsupported Digest algorithm: ${authDetails.algorithm}`);
+ return Promise.reject(error);
+ } else {
+ authDetails.algorithm = 'MD5';
+ }
+
+ const uri = new URL(request.url, request.baseURL || 'http://localhost').pathname; // Handle relative URLs
+ const HA1 = md5(`${username}:${authDetails.realm}:${password}`);
+ const HA2 = md5(`${request.method}:${uri}`);
+ const response = md5(
+ `${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`
+ );
+
+ const headerFields = [
+ `username="${username}"`,
+ `realm="${authDetails.realm}"`,
+ `nonce="${authDetails.nonce}"`,
+ `uri="${uri}"`,
+ `qop="auth"`,
+ `algorithm="${authDetails.algorithm}"`,
+ `response="${response}"`,
+ `nc="${nonceCount}"`,
+ `cnonce="${cnonce}"`,
+ ];
+
+ if (authDetails.opaque) {
+ headerFields.push(`opaque="${authDetails.opaque}"`);
+ }
+
+ const authorizationHeader = `Digest ${headerFields.join(', ')}`;
+
+ // Ensure headers are initialized
+ originalRequest.headers = originalRequest.headers || {};
+ originalRequest.headers['Authorization'] = authorizationHeader;
+
+ console.debug(`Authorization: ${originalRequest.headers['Authorization']}`);
+
+ delete originalRequest.digestConfig;
+
+ return axiosInstance(originalRequest);
+ }
+
+ return Promise.reject(error);
+ }
+ );
+}
diff --git a/packages/bruno-requests/src/auth/index.ts b/packages/bruno-requests/src/auth/index.ts
new file mode 100644
index 000000000..082ca796b
--- /dev/null
+++ b/packages/bruno-requests/src/auth/index.ts
@@ -0,0 +1,2 @@
+export { addDigestInterceptor } from './digestauth-helper';
+export { getOAuth2Token } from './oauth2-helper';
\ No newline at end of file
diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts
new file mode 100644
index 000000000..e73ac7158
--- /dev/null
+++ b/packages/bruno-requests/src/auth/oauth2-helper.ts
@@ -0,0 +1,199 @@
+import axios, { AxiosError } from 'axios';
+import qs from 'qs';
+
+export interface TokenStore {
+ saveToken(serviceId: string, account: string, token: any): Promise;
+ getToken(serviceId: string, account: string): Promise;
+ deleteToken(serviceId: string, account: string): Promise;
+}
+
+export interface OAuth2Config {
+ grantType: 'client_credentials' | 'password';
+ accessTokenUrl: string;
+ clientId?: string;
+ clientSecret?: string;
+ username?: string;
+ password?: string;
+ scope?: string;
+ credentialsPlacement?: 'header' | 'body';
+}
+
+interface RequestConfig {
+ headers: {
+ 'Content-Type': string;
+ 'Authorization'?: string;
+ };
+}
+
+interface ClientCredentialsData {
+ grant_type: string;
+ scope: string;
+ client_id?: string;
+ client_secret?: string;
+}
+
+interface PasswordGrantData {
+ grant_type: string;
+ username: string;
+ password: string;
+ scope: string;
+ client_id?: string;
+ client_secret?: string;
+}
+
+/**
+ * Fetches an OAuth2 token using client credentials grant
+ */
+const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => {
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement = 'header'
+ } = oauth2Config;
+
+ if (!accessTokenUrl || !clientId) {
+ throw new Error('Missing required OAuth2 parameters');
+ }
+
+ const data: ClientCredentialsData = {
+ grant_type: 'client_credentials',
+ scope: scope || ''
+ };
+
+ const config: RequestConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ // Handle credentials placement
+ if (credentialsPlacement === 'header') {
+ config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`;
+ } else {
+ // Credentials in body
+ data.client_id = clientId;
+ if (clientSecret) {
+ data.client_secret = clientSecret;
+ }
+ }
+
+ try {
+ const response = await axios.post(accessTokenUrl, qs.stringify(data), config);
+ return response.data;
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error('CLIENT_CREDENTIALS: Error fetching OAuth2 token:', error.message);
+ }
+ throw error;
+ }
+};
+
+/**
+ * Fetches an OAuth2 token using password grant
+ */
+const fetchTokenPassword = async (oauth2Config: OAuth2Config) => {
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ username,
+ password,
+ scope,
+ credentialsPlacement = 'header'
+ } = oauth2Config;
+
+ if (!accessTokenUrl || !username || !password) {
+ throw new Error('Missing required OAuth2 parameters for password grant');
+ }
+
+ const data: PasswordGrantData = {
+ grant_type: 'password',
+ username,
+ password,
+ scope: scope || ''
+ };
+
+ const config: RequestConfig = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ // Handle credentials placement
+ if (credentialsPlacement === 'header' && clientId) {
+ config.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`;
+ } else if (clientId) {
+ // Credentials in body
+ data.client_id = clientId;
+ if (clientSecret) {
+ data.client_secret = clientSecret;
+ }
+ }
+
+ try {
+ const response = await axios.post(accessTokenUrl, qs.stringify(data), config);
+ return response.data;
+ } catch (error) {
+ if (error instanceof AxiosError && error.response) {
+ console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message);
+ console.error('Status:', error.response.status, 'Response:', error.response.data);
+ } else if (error instanceof Error) {
+ console.error('PASSWORD_GRANT: Error fetching OAuth2 token:', error.message);
+ }
+ throw error;
+ }
+};
+
+/**
+ * Manages OAuth2 token retrieval and storage
+ */
+export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore): Promise => {
+ const { grantType, clientId, accessTokenUrl } = oauth2Config;
+
+ if (!grantType || !accessTokenUrl) {
+ throw new Error('Missing required OAuth2 parameters: grantType or accessTokenUrl');
+ }
+
+ const serviceId = accessTokenUrl;
+ const account = clientId || oauth2Config.username || 'default';
+
+ // Check if we already have a token stored
+ const existingToken = await tokenStore.getToken(serviceId, account);
+
+ if (existingToken) {
+ // Check if token is expired
+ if (existingToken.expires_at && existingToken.expires_at > Date.now()) {
+ return existingToken.access_token;
+ }
+ }
+
+ // No valid token found, fetch a new one
+ try {
+ let tokenResponse;
+
+ if (grantType === 'client_credentials') {
+ tokenResponse = await fetchTokenClientCredentials(oauth2Config);
+ } else if (grantType === 'password') {
+ tokenResponse = await fetchTokenPassword(oauth2Config);
+ } else {
+ throw new Error(`Unsupported grant type: ${grantType}`);
+ }
+
+ // Calculate expiry time if expires_in is provided
+ if (tokenResponse.expires_in) {
+ tokenResponse.expires_at = Date.now() + tokenResponse.expires_in * 1000;
+ }
+
+ // Store the token
+ await tokenStore.saveToken(serviceId, account, tokenResponse);
+
+ return tokenResponse.access_token;
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error('Failed to get OAuth2 token:', error.message);
+ }
+ return null;
+ }
+};
\ No newline at end of file
diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts
new file mode 100644
index 000000000..01850f3e4
--- /dev/null
+++ b/packages/bruno-requests/src/index.ts
@@ -0,0 +1,3 @@
+export { addDigestInterceptor, getOAuth2Token } from './auth';
+
+export * as utils from './utils';
diff --git a/packages/bruno-requests/src/utils/cookie-utils.js b/packages/bruno-requests/src/utils/cookie-utils.js
new file mode 100644
index 000000000..6a1a5ac57
--- /dev/null
+++ b/packages/bruno-requests/src/utils/cookie-utils.js
@@ -0,0 +1,105 @@
+const { URL } = require('node:url');
+const net = require('node:net');
+
+const isLoopbackV4 = (address) => {
+ // 127.0.0.0/8: first octet = 127
+ const octets = address.split('.');
+ return (
+ octets.length === 4
+ ) && parseInt(octets[0], 10) === 127;
+}
+
+const isLoopbackV6 = (address) => {
+ // new URL(...) follows the WHATWG URL Standard
+ // which compresses IPv6 addresses, therefore the IPv6
+ // loopback address will always be compressed to '[::1]':
+ // https://url.spec.whatwg.org/#concept-ipv6-serializer
+ return (address === '::1');
+}
+
+const isIpLoopback = (address) => {
+ if (net.isIPv4(address)) {
+ return isLoopbackV4(address);
+ }
+
+ if (net.isIPv6(address)) {
+ return isLoopbackV6(address);
+ }
+
+ return false;
+}
+
+const isNormalizedLocalhostTLD = (host) => {
+ return host.toLowerCase().endsWith('.localhost');
+}
+
+const isLocalHostname = (host) => {
+ return host.toLowerCase() === 'localhost' ||
+ isNormalizedLocalhostTLD(host);
+}
+
+/**
+ * Removes leading and trailing square brackets if present.
+ * Adapted from https://github.com/chromium/chromium/blob/main/url/gurl.cc#L440-L448
+ *
+ * @param {string} host
+ * @returns {string}
+ */
+const hostNoBrackets = (host) => {
+ if (host.length >= 2 && host.startsWith('[') && host.endsWith(']')) {
+ return host.substring(1, host.length - 1);
+ }
+ return host;
+}
+
+/**
+ * Determines if a URL string represents a potentially trustworthy origin.
+ *
+ * A URL is considered potentially trustworthy if it:
+ * - Uses HTTPS, WSS or file schemes
+ * - Points to a loopback address (IPv4 127.0.0.0/8 or IPv6 ::1)
+ * - Uses localhost or *.localhost hostnames
+ *
+ * @param {string} urlString - The URL to check
+ * @returns {boolean}
+ * @see {@link https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-origin W3C Spec}
+ */
+const isPotentiallyTrustworthyOrigin = (urlString) => {
+ let url;
+
+ // try ... catch doubles as an opaque origin check
+ try {
+ url = new URL(urlString);
+ } catch (e) {
+ if (e instanceof TypeError && e.code === 'ERR_INVALID_URL') {
+ return false;
+ } else throw e;
+ }
+
+ const scheme = url.protocol.replace(':', '').toLowerCase();
+ const hostname = hostNoBrackets(
+ url.hostname
+ ).replace(/\.+$/, '');
+
+ if (
+ scheme === 'https' ||
+ scheme === 'wss' ||
+ scheme === 'file' // https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-origin
+ ) {
+ return true;
+ }
+
+ // If it's already an IP literal, check if it's a loopback address
+ if (net.isIP(hostname)) {
+ return isIpLoopback(hostname);
+ }
+
+ // RFC 6761 states that localhost names will always resolve
+ // to the respective IP loopback address:
+ // https://datatracker.ietf.org/doc/html/rfc6761#section-6.3
+ return isLocalHostname(hostname);
+}
+
+module.exports = {
+ isPotentiallyTrustworthyOrigin
+};
\ No newline at end of file
diff --git a/packages/bruno-requests/src/utils/index.ts b/packages/bruno-requests/src/utils/index.ts
new file mode 100644
index 000000000..dd94dd186
--- /dev/null
+++ b/packages/bruno-requests/src/utils/index.ts
@@ -0,0 +1 @@
+export * from './cookie-utils';
diff --git a/packages/bruno-requests/tsconfig.json b/packages/bruno-requests/tsconfig.json
new file mode 100644
index 000000000..6a74f54cf
--- /dev/null
+++ b/packages/bruno-requests/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "moduleResolution": "node",
+ "declaration": true,
+ "declarationDir": "./dist/types",
+ "allowJs": true,
+ "checkJs": false
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js"],
+ "exclude": ["node_modules", "dist"]
+}
\ No newline at end of file
diff --git a/packages/bruno-schema/package.json b/packages/bruno-schema/package.json
index 1e91a9a1a..61542cf69 100644
--- a/packages/bruno-schema/package.json
+++ b/packages/bruno-schema/package.json
@@ -12,5 +12,8 @@
},
"peerDependencies": {
"yup": "^0.32.11"
+ },
+ "dependencies": {
+ "nanoid": "3.3.8"
}
}
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index 6bf8dd2e4..af4b13434 100644
--- a/packages/bruno-schema/src/collections/index.js
+++ b/packages/bruno-schema/src/collections/index.js
@@ -48,7 +48,7 @@ const varsSchema = Yup.object({
const requestUrlSchema = Yup.string().min(0).defined();
const requestMethodSchema = Yup.string()
- .oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'])
+ .oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'])
.required('method is required');
const graphqlBodySchema = Yup.object({
@@ -68,14 +68,25 @@ const multipartFormSchema = Yup.object({
otherwise: Yup.string().nullable()
}),
description: Yup.string().nullable(),
+ contentType: Yup.string().nullable(),
enabled: Yup.boolean()
})
.noUnknown(true)
.strict();
+
+const fileSchema = Yup.object({
+ uid: uidSchema,
+ filePath: Yup.string().nullable(),
+ contentType: Yup.string().nullable(),
+ selected: Yup.boolean()
+})
+ .noUnknown(true)
+ .strict();
+
const requestBodySchema = Yup.object({
mode: Yup.string()
- .oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql'])
+ .oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
.required('mode is required'),
json: Yup.string().nullable(),
text: Yup.string().nullable(),
@@ -83,7 +94,8 @@ const requestBodySchema = Yup.object({
sparql: Yup.string().nullable(),
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
- graphql: graphqlBodySchema.nullable()
+ graphql: graphqlBodySchema.nullable(),
+ file: Yup.array().of(fileSchema).nullable()
})
.noUnknown(true)
.strict();
@@ -106,6 +118,13 @@ const authBasicSchema = Yup.object({
.noUnknown(true)
.strict();
+const authWsseSchema = Yup.object({
+ username: Yup.string().nullable(),
+ password: Yup.string().nullable()
+})
+ .noUnknown(true)
+ .strict();
+
const authBearerSchema = Yup.object({
token: Yup.string().nullable()
})
@@ -119,6 +138,25 @@ const authDigestSchema = Yup.object({
.noUnknown(true)
.strict();
+
+
+ const authNTLMSchema = Yup.object({
+ username: Yup.string().nullable(),
+ password: Yup.string().nullable(),
+ domain: Yup.string().nullable()
+
+ })
+ .noUnknown(true)
+ .strict();
+
+const authApiKeySchema = Yup.object({
+ key: Yup.string().nullable(),
+ value: Yup.string().nullable(),
+ placement: Yup.string().oneOf(['header', 'queryparams']).nullable()
+})
+ .noUnknown(true)
+ .strict();
+
const oauth2Schema = Yup.object({
grantType: Yup.string()
.oneOf(['client_credentials', 'password', 'authorization_code'])
@@ -163,10 +201,57 @@ const oauth2Schema = Yup.object({
then: Yup.string().nullable(),
otherwise: Yup.string().nullable().strip()
}),
+ state: Yup.string().when('grantType', {
+ is: (val) => ['authorization_code'].includes(val),
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
pkce: Yup.boolean().when('grantType', {
is: (val) => ['authorization_code'].includes(val),
then: Yup.boolean().default(false),
otherwise: Yup.boolean()
+ }),
+ credentialsPlacement: Yup.string().when('grantType', {
+ is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ credentialsId: Yup.string().when('grantType', {
+ is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ tokenPlacement: Yup.string().when('grantType', {
+ is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ tokenHeaderPrefix: Yup.string().when(['grantType', 'tokenPlacement'], {
+ is: (grantType, tokenPlacement) =>
+ ['client_credentials', 'password', 'authorization_code'].includes(grantType) && tokenPlacement === 'header',
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ tokenQueryKey: Yup.string().when(['grantType', 'tokenPlacement'], {
+ is: (grantType, tokenPlacement) =>
+ ['client_credentials', 'password', 'authorization_code'].includes(grantType) && tokenPlacement === 'url',
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ refreshTokenUrl: Yup.string().when('grantType', {
+ is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
+ then: Yup.string().nullable(),
+ otherwise: Yup.string().nullable().strip()
+ }),
+ autoRefreshToken: Yup.boolean().when('grantType', {
+ is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
+ then: Yup.boolean().default(false),
+ otherwise: Yup.boolean()
+ }),
+ autoFetchToken: Yup.boolean().when('grantType', {
+ is: (val) => ['authorization_code'].includes(val),
+ then: Yup.boolean().default(true),
+ otherwise: Yup.boolean()
})
})
.noUnknown(true)
@@ -174,13 +259,28 @@ const oauth2Schema = Yup.object({
const authSchema = Yup.object({
mode: Yup.string()
- .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2'])
+ .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'ntlm', 'oauth2', 'wsse', 'apikey'])
.required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable(),
+ ntlm: authNTLMSchema.nullable(),
digest: authDigestSchema.nullable(),
- oauth2: oauth2Schema.nullable()
+ oauth2: oauth2Schema.nullable(),
+ wsse: authWsseSchema.nullable(),
+ apikey: authApiKeySchema.nullable()
+})
+ .noUnknown(true)
+ .strict()
+ .nullable();
+
+const requestParamsSchema = Yup.object({
+ uid: uidSchema,
+ name: Yup.string().nullable(),
+ value: Yup.string().nullable(),
+ description: Yup.string().nullable(),
+ type: Yup.string().oneOf(['query', 'path']).required('type is required'),
+ enabled: Yup.boolean()
})
.noUnknown(true)
.strict();
@@ -192,7 +292,7 @@ const requestSchema = Yup.object({
url: requestUrlSchema,
method: requestMethodSchema,
headers: Yup.array().of(keyValueSchema).required('headers are required'),
- params: Yup.array().of(keyValueSchema).required('params are required'),
+ params: Yup.array().of(requestParamsSchema).required('params are required'),
auth: authSchema,
body: requestBodySchema,
script: Yup.object({
@@ -215,6 +315,41 @@ const requestSchema = Yup.object({
.noUnknown(true)
.strict();
+const folderRootSchema = Yup.object({
+ request: Yup.object({
+ headers: Yup.array().of(keyValueSchema).nullable(),
+ auth: authSchema,
+ script: Yup.object({
+ req: Yup.string().nullable(),
+ res: Yup.string().nullable()
+ })
+ .noUnknown(true)
+ .strict()
+ .nullable(),
+ vars: Yup.object({
+ req: Yup.array().of(varsSchema).nullable(),
+ res: Yup.array().of(varsSchema).nullable()
+ })
+ .noUnknown(true)
+ .strict()
+ .nullable(),
+ tests: Yup.string().nullable()
+ })
+ .noUnknown(true)
+ .strict()
+ .nullable(),
+ docs: Yup.string().nullable(),
+ meta: Yup.object({
+ name: Yup.string().nullable(),
+ seq: Yup.number().min(1).nullable()
+ })
+ .noUnknown(true)
+ .strict()
+ .nullable()
+})
+ .noUnknown(true)
+ .nullable();
+
const itemSchema = Yup.object({
uid: uidSchema,
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js']).required('type is required'),
@@ -232,6 +367,11 @@ const itemSchema = Yup.object({
// For all other types, the fileContent field is not required and can be null.
otherwise: Yup.string().nullable()
}),
+ root: Yup.mixed().when('type', {
+ is: 'folder',
+ then: folderRootSchema,
+ otherwise: Yup.mixed().nullable().notRequired()
+ }),
items: Yup.lazy(() => Yup.array().of(itemSchema)),
filename: Yup.string().nullable(),
pathname: Yup.string().nullable()
@@ -253,8 +393,9 @@ const collectionSchema = Yup.object({
runnerResult: Yup.object({
items: Yup.array()
}),
- collectionVariables: Yup.object(),
- brunoConfig: Yup.object()
+ runtimeVariables: Yup.object(),
+ brunoConfig: Yup.object(),
+ root: folderRootSchema
})
.noUnknown(true)
.strict();
diff --git a/packages/bruno-schema/src/collections/requestSchema.spec.js b/packages/bruno-schema/src/collections/requestSchema.spec.js
index 87399c690..9fd223cb2 100644
--- a/packages/bruno-schema/src/collections/requestSchema.spec.js
+++ b/packages/bruno-schema/src/collections/requestSchema.spec.js
@@ -32,7 +32,7 @@ describe('Request Schema Validation', () => {
return Promise.all([
expect(requestSchema.validate(request)).rejects.toEqual(
validationErrorWithMessages(
- 'method must be one of the following values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS'
+ 'method must be one of the following values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE'
)
)
]);
diff --git a/packages/bruno-tests/collection/asserts/test-assert-combinations.bru b/packages/bruno-tests/collection/asserts/test-assert-combinations.bru
new file mode 100644
index 000000000..3ad85765a
--- /dev/null
+++ b/packages/bruno-tests/collection/asserts/test-assert-combinations.bru
@@ -0,0 +1,74 @@
+meta {
+ name: test-assert-combinations
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "type": "application/json",
+ "contentJSON": {
+ "string": "foo",
+ "stringWithSQuotes": "'foo'",
+ "stringWithDQuotes": "\"foo\"",
+ "number": 123,
+ "numberAsString": "123",
+ "numberAsStringWithSQuotes": "'123'",
+ "numberAsStringWithDQuotes": "\"123\"",
+ "numberAsStringWithLeadingZero": "0123",
+ "numberBig": 9007199254740992000,
+ "numberBigAsString": "9007199254740991999",
+ "null": null,
+ "nullAsString": "null",
+ "nullAsStringWithSQuotes": "'null'",
+ "nullAsStringWithDQuotes": "\"null\"",
+ "true": true,
+ "trueAsString": "true",
+ "trueAsStringWithSQuotes": "'true'",
+ "trueAsStringWithDQuotes": "\"true\"",
+ "false": false,
+ "falseAsString": "false",
+ "falseAsStringWithSQuotes": "'false'",
+ "falseAsStringWithDQuotes": "\"false\"",
+ "stringWithCurlyBraces": "{foo}",
+ "stringWithDoubleCurlyBraces": "{{foobar}}"
+ }
+ }
+}
+
+assert {
+ res.body.string: eq foo
+ res.body.string: eq 'foo'
+ res.body.string: eq "foo"
+ res.body.stringWithSQuotes: eq "'foo'"
+ res.body.stringWithDQuotes: eq '"foo"'
+ res.body.number: eq 123
+ res.body.numberAsString: eq '123'
+ res.body.numberAsString: eq "123"
+ res.body.numberAsStringWithSQuotes: eq "'123'"
+ res.body.numberAsStringWithDQuotes: eq '"123"'
+ res.body.numberAsStringWithLeadingZero: eq "0123"
+ res.body.numberBig.toString(): eq '9007199254740992000'
+ res.body.numberBigAsString: eq "9007199254740991999"
+ res.body.null: eq null
+ res.body.nullAsString: eq "null"
+ res.body.nullAsStringWithSQuotes: eq "'null'"
+ res.body.nullAsStringWithDQuotes: eq '"null"'
+ res.body.true: eq true
+ res.body.trueAsString: eq "true"
+ res.body.trueAsStringWithSQuotes: eq "'true'"
+ res.body.trueAsStringWithDQuotes: eq '"true"'
+ res.body.false: eq false
+ res.body.falseAsString: eq "false"
+ res.body.falseAsStringWithSQuotes: eq "'false'"
+ res.body.falseAsStringWithDQuotes: eq '"false"'
+ res.body.nonexistent: eq undefined
+ res.body.stringWithCurlyBraces: eq "{foo}"
+ res.body.stringWithDoubleCurlyBraces: eq "{{foobar}}"
+}
diff --git a/packages/bruno-tests/collection/auth/digest/Digest Auth 200.bru b/packages/bruno-tests/collection/auth/digest/Digest Auth 200.bru
new file mode 100644
index 000000000..7efd6bee0
--- /dev/null
+++ b/packages/bruno-tests/collection/auth/digest/Digest Auth 200.bru
@@ -0,0 +1,21 @@
+meta {
+ name: Digest Auth 200
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://httpbin.org/digest-auth/auth/foo/passwd
+ body: none
+ auth: digest
+}
+
+auth:digest {
+ username: foo
+ password: passwd
+}
+
+assert {
+ res.status: eq 200
+ res.body.authenticated: isTruthy
+}
diff --git a/packages/bruno-tests/collection/auth/digest/Digest Auth 401.bru b/packages/bruno-tests/collection/auth/digest/Digest Auth 401.bru
new file mode 100644
index 000000000..52f3698ae
--- /dev/null
+++ b/packages/bruno-tests/collection/auth/digest/Digest Auth 401.bru
@@ -0,0 +1,20 @@
+meta {
+ name: Digest Auth 401
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://httpbin.org/digest-auth/auth/foo/passw
+ body: none
+ auth: digest
+}
+
+auth:digest {
+ username: foo
+ password: passwd
+}
+
+assert {
+ res.status: eq 401
+}
diff --git a/packages/bruno-tests/collection/auth/digest/folder.bru b/packages/bruno-tests/collection/auth/digest/folder.bru
new file mode 100644
index 000000000..6b16b9610
--- /dev/null
+++ b/packages/bruno-tests/collection/auth/digest/folder.bru
@@ -0,0 +1,3 @@
+meta {
+ name: digest
+}
diff --git a/packages/bruno-tests/collection/bruno.json b/packages/bruno-tests/collection/bruno.json
index b6d437bbb..d2aa0a97a 100644
--- a/packages/bruno-tests/collection/bruno.json
+++ b/packages/bruno-tests/collection/bruno.json
@@ -15,7 +15,7 @@
"bypassProxy": ""
},
"scripts": {
- "moduleWhitelist": ["crypto", "buffer"],
+ "moduleWhitelist": ["crypto", "buffer", "form-data"],
"filesystemAccess": {
"allow": true
}
@@ -28,4 +28,4 @@
"requestType": "http",
"requestUrl": "http://localhost:6000"
}
-}
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/bruno.png b/packages/bruno-tests/collection/bruno.png
new file mode 100644
index 000000000..c2a7f878f
Binary files /dev/null and b/packages/bruno-tests/collection/bruno.png differ
diff --git a/packages/bruno-tests/collection/collection.bru b/packages/bruno-tests/collection/collection.bru
index ab9776995..6d84c2abe 100644
--- a/packages/bruno-tests/collection/collection.bru
+++ b/packages/bruno-tests/collection/collection.bru
@@ -1,5 +1,7 @@
headers {
check: again
+ token: {{collection_pre_var_token}}
+ collection-header: collection-header-value
}
auth {
@@ -10,6 +12,33 @@ auth:bearer {
token: {{bearer_auth_token}}
}
+vars:pre-request {
+ collection_pre_var: collection_pre_var_value
+ collection_pre_var_token: {{request_pre_var_token}}
+ collection-var: collection-var-value
+}
+
+script:pre-request {
+ // used by `scripting/js/folder-collection script-tests`
+ const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts');
+ if(shouldTestCollectionScripts) {
+ bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script');
+ }
+}
+
+tests {
+ // used by `scripting/js/folder-collection script-tests`
+ const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts');
+ const collectionVar = bru.getVar("collection-var-set-by-collection-script");
+ if (shouldTestCollectionScripts && collectionVar) {
+ test("collection level test - should get the var that was set by the collection script", function() {
+ expect(collectionVar).to.equal("collection-var-value-set-by-collection-script");
+ });
+ bru.setVar('collection-var-set-by-collection-script', null);
+ bru.setVar('should-test-collection-scripts', null);
+ }
+}
+
docs {
# bruno-testbench 🐶
diff --git a/packages/bruno-tests/collection/echo/echo form-url-encoded.bru b/packages/bruno-tests/collection/echo/echo form-url-encoded.bru
new file mode 100644
index 000000000..7c0ce77eb
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo form-url-encoded.bru
@@ -0,0 +1,26 @@
+meta {
+ name: echo form-url-encoded
+ type: http
+ seq: 9
+}
+
+post {
+ url: {{echo-host}}
+ body: formUrlEncoded
+ auth: none
+}
+
+body:form-urlencoded {
+ form-data-key: {{form-data-key}}
+ form-data-stringified-object: {{form-data-stringified-object}}
+}
+
+assert {
+ res.body: eq form-data-key=form-data-value&form-data-stringified-object=%7B%22foo%22%3A123%7D
+}
+
+script:pre-request {
+ let obj = JSON.stringify({foo:123});
+ bru.setVar('form-data-key', 'form-data-value');
+ bru.setVar('form-data-stringified-object', obj);
+}
diff --git a/packages/bruno-tests/collection/echo/echo headers.bru b/packages/bruno-tests/collection/echo/echo headers.bru
new file mode 100644
index 000000000..9f6571109
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo headers.bru
@@ -0,0 +1,22 @@
+meta {
+ name: echo headers
+ type: http
+ seq: 13
+}
+
+post {
+ url: {{echo-host}}
+ body: none
+ auth: inherit
+}
+
+headers {
+ Custom-Header-String: bruno
+}
+
+tests {
+ test("test headers",function() {
+ expect(res.getHeaders()).to.have.property("Custom-Header-String".toLowerCase())
+ expect(res.getHeaders()).to.have.property("Custom-Header-String".toLowerCase(), "bruno")
+ })
+}
diff --git a/packages/bruno-tests/collection/echo/echo json.bru b/packages/bruno-tests/collection/echo/echo json.bru
index 09a8ed90c..62137591e 100644
--- a/packages/bruno-tests/collection/echo/echo json.bru
+++ b/packages/bruno-tests/collection/echo/echo json.bru
@@ -1,7 +1,7 @@
meta {
name: echo json
type: http
- seq: 1
+ seq: 2
}
post {
@@ -43,6 +43,5 @@ tests {
expect(res.getBody()).to.eql({
"hello": "bruno"
});
- });
-
+ });
}
diff --git a/packages/bruno-tests/collection/echo/echo multipart scripting.bru b/packages/bruno-tests/collection/echo/echo multipart scripting.bru
new file mode 100644
index 000000000..13c1f2051
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo multipart scripting.bru
@@ -0,0 +1,22 @@
+meta {
+ name: echo multipart via scripting
+ type: http
+ seq: 10
+}
+
+post {
+ url: {{echo-host}}
+ body: multipartForm
+ auth: none
+}
+
+assert {
+ res.body: contains form-data-value
+}
+
+script:pre-request {
+ const FormData = require("form-data");
+ const form = new FormData();
+ form.append('form-data-key', 'form-data-value');
+ req.setBody(form);
+}
diff --git a/packages/bruno-tests/collection/echo/echo multipart.bru b/packages/bruno-tests/collection/echo/echo multipart.bru
new file mode 100644
index 000000000..09176d74f
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo multipart.bru
@@ -0,0 +1,30 @@
+meta {
+ name: echo multipart
+ type: http
+ seq: 8
+}
+
+post {
+ url: {{echo-host}}
+ body: multipartForm
+ auth: none
+}
+
+body:multipart-form {
+ foo: {"bar":"baz"} @contentType(application/json--test)
+ form-data-key: {{form-data-key}}
+ form-data-stringified-object: {{form-data-stringified-object}}
+ file: @file(bruno.png)
+}
+
+assert {
+ res.body: contains form-data-value
+ res.body: contains {"foo":123}
+ res.body: contains Content-Type: application/json--test
+}
+
+script:pre-request {
+ let obj = JSON.stringify({foo:123});
+ bru.setVar('form-data-key', 'form-data-value');
+ bru.setVar('form-data-stringified-object', obj);
+}
diff --git a/packages/bruno-tests/collection/echo/echo numbers.bru b/packages/bruno-tests/collection/echo/echo numbers.bru
new file mode 100644
index 000000000..8f68fe558
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo numbers.bru
@@ -0,0 +1,44 @@
+meta {
+ name: echo numbers
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{echo-host}}
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "integer": 123,
+ "negativeInteger": -99,
+ "zero": 0,
+ "float": 2.718,
+ "negativeFloat": -1.618,
+ "largeDouble": 12345.678901234567,
+ "smallDouble": 9.876e-12,
+ "booleanTrue": true,
+ "booleanFalse": false
+ }
+}
+
+assert {
+ res.body.integer: eq 123
+ res.body.integer: isNumber
+ res.body.negativeInteger: eq -99
+ res.body.negativeInteger: isNumber
+ res.body.zero: eq 0
+ res.body.zero: isNumber
+ res.body.float: eq 2.718
+ res.body.float: isNumber
+ res.body.negativeFloat: eq -1.618
+ res.body.negativeFloat: isNumber
+ res.body.largeDouble: eq 12345.678901234567
+ res.body.largeDouble: isNumber
+ res.body.smallDouble: eq 9.876e-12
+ res.body.smallDouble: isNumber
+ res.body.booleanTrue: eq true
+ res.body.booleanFalse: eq false
+}
diff --git a/packages/bruno-tests/collection/echo/echo plaintext.bru b/packages/bruno-tests/collection/echo/echo plaintext.bru
index e6c9b3fdc..56a23d345 100644
--- a/packages/bruno-tests/collection/echo/echo plaintext.bru
+++ b/packages/bruno-tests/collection/echo/echo plaintext.bru
@@ -1,7 +1,7 @@
meta {
name: echo plaintext
type: http
- seq: 2
+ seq: 3
}
post {
diff --git a/packages/bruno-tests/collection/echo/echo xml parsed(self closing tags).bru b/packages/bruno-tests/collection/echo/echo xml parsed(self closing tags).bru
new file mode 100644
index 000000000..d337cebb3
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/echo xml parsed(self closing tags).bru
@@ -0,0 +1,37 @@
+meta {
+ name: echo xml parsed(self closing tags)
+ type: http
+ seq: 6
+}
+
+post {
+ url: {{host}}/api/echo/xml-parsed
+ body: xml
+ auth: none
+}
+
+body:xml {
+
+ bruno
+
+
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("should return parsed xml", function() {
+ const data = res.getBody();
+ expect(res.getBody()).to.eql({
+ "hello": {
+ "world": [
+ "bruno",
+ ""
+ ]
+ }
+ });
+ });
+
+}
diff --git a/packages/bruno-tests/collection/echo/echo xml parsed.bru b/packages/bruno-tests/collection/echo/echo xml parsed.bru
index a8ff5e26a..acd24a292 100644
--- a/packages/bruno-tests/collection/echo/echo xml parsed.bru
+++ b/packages/bruno-tests/collection/echo/echo xml parsed.bru
@@ -1,7 +1,7 @@
meta {
name: echo xml parsed
type: http
- seq: 3
+ seq: 4
}
post {
@@ -25,9 +25,7 @@ tests {
const data = res.getBody();
expect(res.getBody()).to.eql({
"hello": {
- "world": [
- "bruno"
- ]
+ "world": ["bruno"]
}
});
});
diff --git a/packages/bruno-tests/collection/echo/echo xml raw.bru b/packages/bruno-tests/collection/echo/echo xml raw.bru
index 9773d4a3d..6a02ac238 100644
--- a/packages/bruno-tests/collection/echo/echo xml raw.bru
+++ b/packages/bruno-tests/collection/echo/echo xml raw.bru
@@ -1,7 +1,7 @@
meta {
name: echo xml raw
type: http
- seq: 4
+ seq: 5
}
post {
diff --git a/packages/bruno-tests/collection/echo/multiline/echo binary.bru b/packages/bruno-tests/collection/echo/multiline/echo binary.bru
new file mode 100644
index 000000000..704419886
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/multiline/echo binary.bru
@@ -0,0 +1,15 @@
+meta {
+ name: echo binary
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{echo-host}}
+ body: file
+ auth: none
+}
+
+body:file {
+ file: @file(bruno.png) @contentType(image/png)
+}
diff --git a/packages/bruno-tests/collection/echo/test echo any.bru b/packages/bruno-tests/collection/echo/test echo any.bru
new file mode 100644
index 000000000..b78014dd9
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/test echo any.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test echo any
+ type: http
+ seq: 11
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain" },
+ "content": "hello"
+ }
+}
+
+assert {
+ res.body: eq hello
+}
diff --git a/packages/bruno-tests/collection/echo/test echo-any json.bru b/packages/bruno-tests/collection/echo/test echo-any json.bru
new file mode 100644
index 000000000..2f3a7e5f4
--- /dev/null
+++ b/packages/bruno-tests/collection/echo/test echo-any json.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test echo-any json
+ type: http
+ seq: 12
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "type": "application/json",
+ "contentJSON": {"x": 42}
+ }
+}
+
+assert {
+ res.body.x: eq 42
+}
diff --git a/packages/bruno-tests/collection/environments/Local.bru b/packages/bruno-tests/collection/environments/Local.bru
index 991077d97..a7ac3f541 100644
--- a/packages/bruno-tests/collection/environments/Local.bru
+++ b/packages/bruno-tests/collection/environments/Local.bru
@@ -1,7 +1,14 @@
vars {
host: http://localhost:8080
+ httpfaker: https://www.httpfaker.org
bearer_auth_token: your_secret_token
basic_auth_password: della
+ env.var1: envVar1
+ env-var2: envVar2
+ bark: {{process.env.PROC_ENV_VAR}}
+ foo: bar
+ testSetEnvVar: bruno-29653
+ echo-host: https://echo.usebruno.com
client_id: client_id_1
client_secret: client_secret_1
auth_url: http://localhost:8080/api/auth/oauth2/authorization_code/authorize
diff --git a/packages/bruno-tests/collection/environments/Prod.bru b/packages/bruno-tests/collection/environments/Prod.bru
index e6286f3b6..f33c1bb05 100644
--- a/packages/bruno-tests/collection/environments/Prod.bru
+++ b/packages/bruno-tests/collection/environments/Prod.bru
@@ -1,8 +1,12 @@
vars {
host: https://testbench-sanity.usebruno.com
+ httpfaker: https://www.httpfaker.org
bearer_auth_token: your_secret_token
basic_auth_password: della
env.var1: envVar1
env-var2: envVar2
bark: {{process.env.PROC_ENV_VAR}}
+ foo: bar
+ testSetEnvVar: bruno-29653
+ echo-host: https://echo.usebruno.com
}
diff --git a/packages/bruno-tests/collection/file.txt b/packages/bruno-tests/collection/file.txt
new file mode 100644
index 000000000..0a1443d43
--- /dev/null
+++ b/packages/bruno-tests/collection/file.txt
@@ -0,0 +1,3 @@
+file.txt
+
+hello, bruno
diff --git a/packages/bruno-tests/collection/lib/constants.js b/packages/bruno-tests/collection/lib/constants.js
new file mode 100644
index 000000000..2f0e62f37
--- /dev/null
+++ b/packages/bruno-tests/collection/lib/constants.js
@@ -0,0 +1,5 @@
+const PI = 3.14;
+
+module.exports = {
+ PI
+};
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/lib/math.js b/packages/bruno-tests/collection/lib/math.js
index da6a05ef3..c25446734 100644
--- a/packages/bruno-tests/collection/lib/math.js
+++ b/packages/bruno-tests/collection/lib/math.js
@@ -1,5 +1,9 @@
+const { PI } = require('./constants');
+
const sum = (a, b) => a + b;
+const areaOfCircle = (radius) => PI * radius * radius;
module.exports = {
- sum
+ sum,
+ areaOfCircle
};
diff --git a/packages/bruno-tests/collection/multipart/small.png b/packages/bruno-tests/collection/multipart/small.png
new file mode 100644
index 000000000..2b584adf0
Binary files /dev/null and b/packages/bruno-tests/collection/multipart/small.png differ
diff --git a/packages/bruno-tests/collection/package-lock.json b/packages/bruno-tests/collection/package-lock.json
index b8b4283ae..0f6e06f56 100644
--- a/packages/bruno-tests/collection/package-lock.json
+++ b/packages/bruno-tests/collection/package-lock.json
@@ -10,7 +10,254 @@
"dependencies": {
"@faker-js/faker": "^8.4.0",
"jsonwebtoken": "^9.0.2",
- "lru-map-cache": "^0.1.0"
+ "lru-map-cache": "^0.1.0",
+ "mssql": "^11.0.1"
+ }
+ },
+ "node_modules/@azure/abort-controller": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
+ "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-auth": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz",
+ "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-util": "^1.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-client": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.2.tgz",
+ "integrity": "sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.4.0",
+ "@azure/core-rest-pipeline": "^1.9.1",
+ "@azure/core-tracing": "^1.0.0",
+ "@azure/core-util": "^1.6.1",
+ "@azure/logger": "^1.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-http-compat": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.1.2.tgz",
+ "integrity": "sha512-5MnV1yqzZwgNLLjlizsU3QqOeQChkIXw781Fwh1xdAqJR5AA32IUaq6xv1BICJvfbHoa+JYcaij2HFkhLbNTJQ==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-client": "^1.3.0",
+ "@azure/core-rest-pipeline": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-lro": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
+ "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-util": "^1.2.0",
+ "@azure/logger": "^1.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-paging": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
+ "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-rest-pipeline": {
+ "version": "1.18.1",
+ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.18.1.tgz",
+ "integrity": "sha512-/wS73UEDrxroUEVywEm7J0p2c+IIiVxyfigCGfsKvCxxCET4V/Hef2aURqltrXMRjNmdmt5IuOgIpl8f6xdO5A==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.8.0",
+ "@azure/core-tracing": "^1.0.1",
+ "@azure/core-util": "^1.11.0",
+ "@azure/logger": "^1.0.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-tracing": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz",
+ "integrity": "sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/core-util": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz",
+ "integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/identity": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.5.0.tgz",
+ "integrity": "sha512-EknvVmtBuSIic47xkOqyNabAme0RYTw52BTMz8eBgU1ysTyMrD1uOoM+JdS0J/4Yfp98IBT3osqq3BfwSaNaGQ==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.9.0",
+ "@azure/core-client": "^1.9.2",
+ "@azure/core-rest-pipeline": "^1.17.0",
+ "@azure/core-tracing": "^1.0.0",
+ "@azure/core-util": "^1.11.0",
+ "@azure/logger": "^1.0.0",
+ "@azure/msal-browser": "^3.26.1",
+ "@azure/msal-node": "^2.15.0",
+ "events": "^3.0.0",
+ "jws": "^4.0.0",
+ "open": "^8.0.0",
+ "stoppable": "^1.1.0",
+ "tslib": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/identity/node_modules/jwa": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
+ "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
+ "dependencies": {
+ "buffer-equal-constant-time": "1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/@azure/identity/node_modules/jws": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
+ "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "dependencies": {
+ "jwa": "^2.0.0",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/@azure/keyvault-common": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz",
+ "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.3.0",
+ "@azure/core-client": "^1.5.0",
+ "@azure/core-rest-pipeline": "^1.8.0",
+ "@azure/core-tracing": "^1.0.0",
+ "@azure/core-util": "^1.10.0",
+ "@azure/logger": "^1.1.4",
+ "tslib": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/keyvault-keys": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.9.0.tgz",
+ "integrity": "sha512-ZBP07+K4Pj3kS4TF4XdkqFcspWwBHry3vJSOFM5k5ZABvf7JfiMonvaFk2nBF6xjlEbMpz5PE1g45iTMme0raQ==",
+ "dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-auth": "^1.3.0",
+ "@azure/core-client": "^1.5.0",
+ "@azure/core-http-compat": "^2.0.1",
+ "@azure/core-lro": "^2.2.0",
+ "@azure/core-paging": "^1.1.1",
+ "@azure/core-rest-pipeline": "^1.8.1",
+ "@azure/core-tracing": "^1.0.0",
+ "@azure/core-util": "^1.0.0",
+ "@azure/keyvault-common": "^2.0.0",
+ "@azure/logger": "^1.0.0",
+ "tslib": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/logger": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.1.4.tgz",
+ "integrity": "sha512-4IXXzcCdLdlXuCG+8UKEwLA1T1NHqUfanhXYHiQTn+6sfWCZXduqbtXDGceg3Ce5QxTGo7EqmbV6Bi+aqKuClQ==",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@azure/msal-browser": {
+ "version": "3.28.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.28.0.tgz",
+ "integrity": "sha512-1c1qUF6vB52mWlyoMem4xR1gdwiQWYEQB2uhDkbAL4wVJr8WmAcXybc1Qs33y19N4BdPI8/DHI7rPE8L5jMtWw==",
+ "dependencies": {
+ "@azure/msal-common": "14.16.0"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-common": {
+ "version": "14.16.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz",
+ "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-node": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.2.tgz",
+ "integrity": "sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==",
+ "dependencies": {
+ "@azure/msal-common": "14.16.0",
+ "jsonwebtoken": "^9.0.0",
+ "uuid": "^8.3.0"
+ },
+ "engines": {
+ "node": ">=16"
}
},
"node_modules/@faker-js/faker": {
@@ -28,11 +275,147 @@
"npm": ">=6.14.13"
}
},
+ "node_modules/@js-joda/core": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.6.3.tgz",
+ "integrity": "sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA=="
+ },
+ "node_modules/@tediousjs/connection-string": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
+ "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="
+ },
+ "node_modules/@types/node": {
+ "version": "22.10.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
+ "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==",
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "node_modules/@types/readable-stream": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz",
+ "integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==",
+ "dependencies": {
+ "@types/node": "*",
+ "safe-buffer": "~5.1.1"
+ }
+ },
+ "node_modules/@types/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
+ "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/bl": {
+ "version": "6.0.18",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-6.0.18.tgz",
+ "integrity": "sha512-2k76XmWCuvu9HTvu3tFOl5HDdCH0wLZ/jHYva/LBVJmc9oX8yUtNQjxrFmbTdXsCSmIxwVTANZPNDfMQrvHFUw==",
+ "dependencies": {
+ "@types/readable-stream": "^4.0.0",
+ "buffer": "^6.0.3",
+ "inherits": "^2.0.4",
+ "readable-stream": "^4.2.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
+ "node_modules/commander": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
+ "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+ "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/define-lazy-prop": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
+ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -41,6 +424,111 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-md4": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
+ "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="
+ },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -137,6 +625,74 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
+ "node_modules/mssql": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz",
+ "integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==",
+ "dependencies": {
+ "@tediousjs/connection-string": "^0.5.0",
+ "commander": "^11.0.0",
+ "debug": "^4.3.3",
+ "rfdc": "^1.3.0",
+ "tarn": "^3.0.2",
+ "tedious": "^18.2.1"
+ },
+ "bin": {
+ "mssql": "bin/mssql"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/native-duplexpair": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
+ "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="
+ },
+ "node_modules/open": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
+ "dependencies": {
+ "define-lazy-prop": "^2.0.0",
+ "is-docker": "^2.1.1",
+ "is-wsl": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -156,6 +712,11 @@
}
]
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
"node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
@@ -170,6 +731,74 @@
"node": ">=10"
}
},
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
+ },
+ "node_modules/stoppable": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
+ "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
+ "engines": {
+ "node": ">=4",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/tarn": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
+ "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/tedious": {
+ "version": "18.6.1",
+ "resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.1.tgz",
+ "integrity": "sha512-9AvErXXQTd6l7TDd5EmM+nxbOGyhnmdbp/8c3pw+tjaiSXW9usME90ET/CRG1LN1Y9tPMtz/p83z4Q97B4DDpw==",
+ "dependencies": {
+ "@azure/core-auth": "^1.7.2",
+ "@azure/identity": "^4.2.1",
+ "@azure/keyvault-keys": "^4.4.0",
+ "@js-joda/core": "^5.6.1",
+ "@types/node": ">=18",
+ "bl": "^6.0.11",
+ "iconv-lite": "^0.6.3",
+ "js-md4": "^0.3.2",
+ "native-duplexpair": "^1.0.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
diff --git a/packages/bruno-tests/collection/ping.bru b/packages/bruno-tests/collection/ping.bru
index d4133c369..8f4f3c6f7 100644
--- a/packages/bruno-tests/collection/ping.bru
+++ b/packages/bruno-tests/collection/ping.bru
@@ -10,51 +10,6 @@ get {
auth: none
}
-auth:awsv4 {
- accessKeyId: a
- secretAccessKey: b
- sessionToken: c
- service: d
- region: e
- profileName: f
-}
-
-vars:pre-request {
- m4: true
- pong: pong
-}
-
-assert {
- res.status: eq 200
- res.responseTime: lte 2000
- ~res.body: eq {{pong}}
-}
-
-tests {
- test("should ping pong", function() {
- const data = res.getBody();
- expect(data).to.equal(bru.getVar("pong"));
- });
-}
-
-docs {
- # API Documentation
-
- ## Introduction
-
- Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
-
- ## Authentication
-
- Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
-
- ### API Key
-
- To use API key authentication, include your API key in the request headers as follows:
-
- ```http
- GET /api/endpoint
- Host: api.example.com
- Authorization: Bearer YOUR_API_KEY
-
+script:pre-request {
+ bru.runner.stopExecution();
}
diff --git a/packages/bruno-tests/collection/preview/image/bruno.bru b/packages/bruno-tests/collection/preview/image/bruno.bru
index bb773d91c..d8c6b74d1 100644
--- a/packages/bruno-tests/collection/preview/image/bruno.bru
+++ b/packages/bruno-tests/collection/preview/image/bruno.bru
@@ -5,7 +5,7 @@ meta {
}
get {
- url: https://www.usebruno.com/images/landing-2.png
+ url: https://www.usebruno.com/favicon.ico
body: none
auth: none
}
@@ -13,7 +13,7 @@ get {
tests {
test("should return parsed xml", function() {
const headers = res.getHeaders();
- expect(headers['content-type']).to.eql("image/png");
+ expect(headers['content-type']).to.eql("image/x-icon");
});
}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON false response.bru b/packages/bruno-tests/collection/response-parsing/test JSON false response.bru
new file mode 100644
index 000000000..a507434e5
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON false response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON false response
+ type: http
+ seq: 11
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "false"
+ }
+}
+
+assert {
+ res.body: eq false
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON null response.bru b/packages/bruno-tests/collection/response-parsing/test JSON null response.bru
new file mode 100644
index 000000000..156aa7fbe
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON null response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON null response
+ type: http
+ seq: 6
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "null"
+ }
+}
+
+assert {
+ res.body: eq null
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON number response.bru b/packages/bruno-tests/collection/response-parsing/test JSON number response.bru
new file mode 100644
index 000000000..8f995b395
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON number response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON number response
+ type: http
+ seq: 12
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "3.1"
+ }
+}
+
+assert {
+ res.body: eq 3.1
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON response.bru b/packages/bruno-tests/collection/response-parsing/test JSON response.bru
new file mode 100644
index 000000000..018cce86d
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON response
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "contentJSON": { "message": "hello" }
+ }
+}
+
+assert {
+ res.body.message: eq hello
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON string response.bru b/packages/bruno-tests/collection/response-parsing/test JSON string response.bru
new file mode 100644
index 000000000..18a0e3909
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON string response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON string response
+ type: http
+ seq: 7
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "\"ok\""
+ }
+}
+
+assert {
+ res.body: eq ok
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON string with quotes response.bru b/packages/bruno-tests/collection/response-parsing/test JSON string with quotes response.bru
new file mode 100644
index 000000000..d262ff084
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON string with quotes response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON string with quotes response
+ type: http
+ seq: 8
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "contentJSON": "\"ok\""
+ }
+}
+
+assert {
+ res.body: eq '"ok"'
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON true response.bru b/packages/bruno-tests/collection/response-parsing/test JSON true response.bru
new file mode 100644
index 000000000..7f4c0bd65
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON true response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test JSON true response
+ type: http
+ seq: 10
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "true"
+ }
+}
+
+assert {
+ res.body: eq true
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test JSON unsafe-int response.bru b/packages/bruno-tests/collection/response-parsing/test JSON unsafe-int response.bru
new file mode 100644
index 000000000..db4fe3bcc
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test JSON unsafe-int response.bru
@@ -0,0 +1,26 @@
+meta {
+ name: test JSON unsafe-int response
+ type: http
+ seq: 13
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "90071992547409919876"
+ }
+}
+
+assert {
+ res.body.toString(): eq 90071992547409920000
+}
+
+docs {
+ Note: This test is not perfect, we should match the unparsed raw-response with the expected string version of the unsafe-integer
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test binary response.bru b/packages/bruno-tests/collection/response-parsing/test binary response.bru
new file mode 100644
index 000000000..53e6e3436
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test binary response.bru
@@ -0,0 +1,34 @@
+meta {
+ name: test binary response
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "type": "application/octet-stream",
+ "contentBase64": "+Z1P82iH1wmbILfvnhvjQVbVAktP4TzltpxYD74zNyA="
+ }
+}
+
+tests {
+ test("response matches the expectation after utf-8 decoding(needs improvement)", function () {
+ expect(res.getStatus()).to.equal(200);
+ const dataBinary = Buffer.from("+Z1P82iH1wmbILfvnhvjQVbVAktP4TzltpxYD74zNyA=", "base64");
+ expect(res.body).to.equal(dataBinary.toString("utf-8"));
+ });
+}
+
+docs {
+ Note:
+
+ This test is not perfect and needs to be improved by direclty matching expected binary data with raw-response.
+
+ Currently res.body is decoded with `utf-8` by default and looses data in the process. We need some property in `res` which gives access to raw-data/Buffer.
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test html response.bru b/packages/bruno-tests/collection/response-parsing/test html response.bru
new file mode 100644
index 000000000..48bf15310
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test html response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test html response
+ type: http
+ seq: 5
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/html" },
+ "content": "hello "
+ }
+}
+
+assert {
+ res.body: eq hello
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test image response.bru b/packages/bruno-tests/collection/response-parsing/test image response.bru
new file mode 100644
index 000000000..4ca65adab
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test image response.bru
@@ -0,0 +1,18 @@
+meta {
+ name: test image response
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "type": "image/png",
+ "contentBase64": "iVBORw0KGgoAAAANSUhEUgAAAGQAAABkAQMAAABKLAcXAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURQCqAP///59OGOoAAAABYktHRAH/Ai3eAAAAB3RJTUUH6QMHCwUNKHvFmgAAABRJREFUOMtjYBgFo2AUjIJRQE8AAAV4AAEpcbn8AAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI1LTAzLTA3VDExOjA1OjEzKzAwOjAwQkgGWgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0wMy0wN1QxMTowNToxMyswMDowMDMVvuYAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjUtMDMtMDdUMTE6MDU6MTMrMDA6MDBkAJ85AAAAAElFTkSuQmCC"
+ }
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test invalid JSON response with formatting.bru b/packages/bruno-tests/collection/response-parsing/test invalid JSON response with formatting.bru
new file mode 100644
index 000000000..57e2a6872
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test invalid JSON response with formatting.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test invalid JSON response with formatting
+ type: http
+ seq: 19
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/json" },
+ "content": "hello\n\tworld"
+ }
+}
+
+assert {
+ res.body: eq hello\n\tworld
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text response with formatting.bru b/packages/bruno-tests/collection/response-parsing/test plain text response with formatting.bru
new file mode 100644
index 000000000..af9a87144
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text response with formatting.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test plain text response with formatting
+ type: http
+ seq: 18
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain" },
+ "content": "hello\n\tworld"
+ }
+}
+
+assert {
+ res.body: eq hello\n\tworld
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text response.bru b/packages/bruno-tests/collection/response-parsing/test plain text response.bru
new file mode 100644
index 000000000..fbb884483
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text response.bru
@@ -0,0 +1,23 @@
+meta {
+ name: test plain text response
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain" },
+ "content": "hello"
+ }
+}
+
+assert {
+ res.body: eq hello
+}
+
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text utf16 response.bru b/packages/bruno-tests/collection/response-parsing/test plain text utf16 response.bru
new file mode 100644
index 000000000..096985f0a
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text utf16 response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test plain text utf16 response
+ type: http
+ seq: 14
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain; charset=utf-16" },
+ "contentBase64": "dABoAGkAcwAgAGkAcwAgAGUAbgBjAG8AZABlAGQAIAB3AGkAdABoACAAdQB0AGYAMQA2AA=="
+ }
+}
+
+assert {
+ res.body: eq "this is encoded with utf16"
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text utf16-be with BOM response.bru b/packages/bruno-tests/collection/response-parsing/test plain text utf16-be with BOM response.bru
new file mode 100644
index 000000000..cffe54a97
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text utf16-be with BOM response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test plain text utf16-be with BOM response
+ type: http
+ seq: 15
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain; charset=utf-16" },
+ "contentBase64": "/v8AdABoAGkAcwAgAGkAcwAgAGUAbgBjAG8AZABlAGQAIAB3AGkAdABoACAAdQB0AGYAMQA2AC0AYgBlACAAdwBpAHQAaAAgAEIATwBN"
+ }
+}
+
+assert {
+ res.body: eq "this is encoded with utf16-be with BOM"
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text utf16-le with BOM response.bru b/packages/bruno-tests/collection/response-parsing/test plain text utf16-le with BOM response.bru
new file mode 100644
index 000000000..5d29a2701
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text utf16-le with BOM response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test plain text utf16-le with BOM response
+ type: http
+ seq: 16
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain; charset=utf-16" },
+ "contentBase64": "//50AGgAaQBzACAAaQBzACAAZQBuAGMAbwBkAGUAZAAgAHcAaQB0AGgAIAB1AHQAZgAxADYALQBsAGUAIAB3AGkAdABoACAAQgBPAE0A"
+ }
+}
+
+assert {
+ res.body: eq "this is encoded with utf16-le with BOM"
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test plain text utf8 with BOM response.bru b/packages/bruno-tests/collection/response-parsing/test plain text utf8 with BOM response.bru
new file mode 100644
index 000000000..055386d79
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test plain text utf8 with BOM response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test plain text utf8 with BOM response
+ type: http
+ seq: 17
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "text/plain; charset=utf8" },
+ "contentBase64": "77u/dGhpcyBpcyB1dGY4IGVuY29kZWQgd2l0aCBCT00sIHdoeSBub3Q/"
+ }
+}
+
+assert {
+ res.body: eq "this is utf8 encoded with BOM, why not?"
+}
diff --git a/packages/bruno-tests/collection/response-parsing/test xml response.bru b/packages/bruno-tests/collection/response-parsing/test xml response.bru
new file mode 100644
index 000000000..5e562ada2
--- /dev/null
+++ b/packages/bruno-tests/collection/response-parsing/test xml response.bru
@@ -0,0 +1,22 @@
+meta {
+ name: test xml response
+ type: http
+ seq: 9
+}
+
+post {
+ url: {{httpfaker}}/api/echo/custom
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "headers": { "content-type": "application/xml" },
+ "content": "hello "
+ }
+}
+
+assert {
+ res.body: eq hello
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/folder.bru b/packages/bruno-tests/collection/scripting/api/bru/folder.bru
new file mode 100644
index 000000000..7cb15610d
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/folder.bru
@@ -0,0 +1,7 @@
+meta {
+ name: bru
+}
+
+vars:pre-request {
+ folder-var: folder-var-value
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getCollectionName.bru b/packages/bruno-tests/collection/scripting/api/bru/getCollectionName.bru
new file mode 100644
index 000000000..3302d38fd
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getCollectionName.bru
@@ -0,0 +1,17 @@
+meta {
+ name: getCollectionName
+ type: http
+ seq: 13
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: inherit
+}
+
+tests {
+ test("Check if collection name is bruno-testbench", function () {
+ expect(bru.getCollectionName()).to.eql("bruno-testbench");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getCollectionVar.bru b/packages/bruno-tests/collection/scripting/api/bru/getCollectionVar.bru
new file mode 100644
index 000000000..b5a91c600
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getCollectionVar.bru
@@ -0,0 +1,18 @@
+meta {
+ name: getCollectionVar
+ type: http
+ seq: 9
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("should get collection var in scripts", function() {
+ const testVar = bru.getCollectionVar("collection-var");
+ expect(testVar).to.equal("collection-var-value");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getEnvName.bru b/packages/bruno-tests/collection/scripting/api/bru/getEnvName.bru
new file mode 100644
index 000000000..4e7c37ec3
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getEnvName.bru
@@ -0,0 +1,23 @@
+meta {
+ name: getEnvName
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ const envName = bru.getEnvName();
+ bru.setVar("testEnvName", envName);
+}
+
+tests {
+ test("should get env name in scripts", function() {
+ const testEnvName = bru.getVar("testEnvName");
+ expect(testEnvName).to.equal("Prod");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getEnvVar.bru b/packages/bruno-tests/collection/scripting/api/bru/getEnvVar.bru
new file mode 100644
index 000000000..6b0276415
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getEnvVar.bru
@@ -0,0 +1,19 @@
+meta {
+ name: getEnvVar
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+tests {
+ test("should get env var in scripts", function() {
+ const host = bru.getEnvVar("host")
+ expect(host).to.equal("https://testbench-sanity.usebruno.com");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getFolderVar.bru b/packages/bruno-tests/collection/scripting/api/bru/getFolderVar.bru
new file mode 100644
index 000000000..425799451
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getFolderVar.bru
@@ -0,0 +1,18 @@
+meta {
+ name: getFolderVar
+ type: http
+ seq: 8
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("should get folder var in scripts", function() {
+ const testVar = bru.getFolderVar("folder-var");
+ expect(testVar).to.equal("folder-var-value");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getProcessEnv.bru b/packages/bruno-tests/collection/scripting/api/bru/getProcessEnv.bru
new file mode 100644
index 000000000..b0836a504
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getProcessEnv.bru
@@ -0,0 +1,19 @@
+meta {
+ name: getProcessEnv
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+tests {
+ test("bru.getProcessEnv()", function() {
+ const v = bru.getProcessEnv("PROC_ENV_VAR");
+ expect(v).to.equal("woof");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getRequestVar.bru b/packages/bruno-tests/collection/scripting/api/bru/getRequestVar.bru
new file mode 100644
index 000000000..d2deff4c3
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getRequestVar.bru
@@ -0,0 +1,22 @@
+meta {
+ name: getRequestVar
+ type: http
+ seq: 7
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+vars:pre-request {
+ request-var: request-var-value
+}
+
+tests {
+ test("should get request var in scripts", function() {
+ const testVar = bru.getRequestVar("request-var");
+ expect(testVar).to.equal("request-var-value");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getVar.bru b/packages/bruno-tests/collection/scripting/api/bru/getVar.bru
new file mode 100644
index 000000000..96e7c365a
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/getVar.bru
@@ -0,0 +1,19 @@
+meta {
+ name: getVar
+ type: http
+ seq: 5
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+tests {
+ test("should get var in scripts", function() {
+ const testSetVar = bru.getVar("testSetVar");
+ expect(testSetVar).to.equal("bruno-test-87267");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/bru/interpolate.bru b/packages/bruno-tests/collection/scripting/api/bru/interpolate.bru
new file mode 100644
index 000000000..a8e6dff76
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/interpolate.bru
@@ -0,0 +1,39 @@
+meta {
+ name: interpolate
+ type: http
+ seq: 13
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("should interpolate envs", function() {
+ const interpolated = bru.interpolate("url: {{host}}")
+ expect(interpolated).to.equal("url: https://testbench-sanity.usebruno.com");
+ });
+
+ test("should interpolate random variables", function() {
+ const a = bru.interpolate("{{$randomInt}}")
+ const b = bru.interpolate("{{$randomInt}}")
+ expect(a).to.not.equal(b)
+ });
+
+ const randomObj = {
+ host: "{{host}}",
+ int: "{{$randomInt}}",
+ timestamp: "{{$timestamp}}"
+ }
+
+ test("should interpolate objects with vars, random vars", function() {
+ const objA = bru.interpolate(randomObj)
+ const objB = bru.interpolate(randomObj)
+
+ expect(objA).to.be.an("object")
+ expect(objB).to.be.an("object")
+ expect(objA).to.not.deep.eql(objB)
+ });
+}
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/scripting/api/bru/setEnvVar.bru b/packages/bruno-tests/collection/scripting/api/bru/setEnvVar.bru
new file mode 100644
index 000000000..cd0e98151
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/setEnvVar.bru
@@ -0,0 +1,23 @@
+meta {
+ name: setEnvVar
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+script:post-response {
+ bru.setEnvVar("testSetEnvVar", "bruno-29653")
+}
+
+tests {
+ test("should set env var in scripts", function() {
+ const testSetEnvVar = bru.getEnvVar("testSetEnvVar")
+ expect(testSetEnvVar).to.equal("bruno-29653");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/bru/setVar.bru b/packages/bruno-tests/collection/scripting/api/bru/setVar.bru
new file mode 100644
index 000000000..a155117c9
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/setVar.bru
@@ -0,0 +1,22 @@
+meta {
+ name: setVar
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setVar("testSetVar", "bruno-test-87267")
+}
+
+tests {
+ test("should get var in scripts", function() {
+ const testSetVar = bru.getVar("testSetVar");
+ expect(testSetVar).to.equal("bruno-test-87267");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/req/getBody.bru b/packages/bruno-tests/collection/scripting/api/req/getBody.bru
new file mode 100644
index 000000000..926144ed7
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getBody.bru
@@ -0,0 +1,40 @@
+meta {
+ name: getBody
+ type: http
+ seq: 9
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("req.getBody()", function() {
+ const data = res.getBody();
+ expect(data).to.eql({
+ "hello": "bruno"
+ });
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/req/getHeader.bru b/packages/bruno-tests/collection/scripting/api/req/getHeader.bru
new file mode 100644
index 000000000..77a2462cc
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getHeader.bru
@@ -0,0 +1,28 @@
+meta {
+ name: getHeader
+ type: http
+ seq: 5
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+headers {
+ bruno: is-awesome
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+tests {
+ test("req.getHeader(name)", function() {
+ const h = req.getHeader('bruno');
+ expect(h).to.equal("is-awesome");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/getHeaders.bru b/packages/bruno-tests/collection/scripting/api/req/getHeaders.bru
new file mode 100644
index 000000000..3ab422615
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getHeaders.bru
@@ -0,0 +1,30 @@
+meta {
+ name: getHeaders
+ type: http
+ seq: 7
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+headers {
+ bruno: is-awesome
+ della: is-beautiful
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+tests {
+ test("req.getHeaders()", function() {
+ const h = req.getHeaders();
+ expect(h.bruno).to.equal("is-awesome");
+ expect(h.della).to.equal("is-beautiful");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/getMethod.bru b/packages/bruno-tests/collection/scripting/api/req/getMethod.bru
new file mode 100644
index 000000000..eb730405c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getMethod.bru
@@ -0,0 +1,24 @@
+meta {
+ name: getMethod
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+tests {
+ test("req.getMethod()()", function() {
+ const method = req.getMethod();
+ expect(method).to.equal("GET");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/getName.bru b/packages/bruno-tests/collection/scripting/api/req/getName.bru
new file mode 100644
index 000000000..95e0369f5
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getName.bru
@@ -0,0 +1,17 @@
+meta {
+ name: getName
+ type: http
+ seq: 11
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: inherit
+}
+
+tests {
+ test("Check if request name is getName", function () {
+ expect(req.getName()).to.eql("getName");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/req/getUrl.bru b/packages/bruno-tests/collection/scripting/api/req/getUrl.bru
new file mode 100644
index 000000000..155a40b7a
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/getUrl.bru
@@ -0,0 +1,23 @@
+meta {
+ name: getUrl
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+tests {
+ test("req.getUrl()", function() {
+ const url = req.getUrl();
+ expect(url).to.equal("https://testbench-sanity.usebruno.com/ping");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/req/setBody.bru b/packages/bruno-tests/collection/scripting/api/req/setBody.bru
new file mode 100644
index 000000000..ee609bd0b
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/setBody.bru
@@ -0,0 +1,46 @@
+meta {
+ name: setBody
+ type: http
+ seq: 10
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:pre-request {
+ req.setBody({
+ "bruno": "is awesome"
+ });
+}
+
+tests {
+ test("req.setBody()", function() {
+ const data = res.getBody();
+ expect(data).to.eql({
+ "bruno": "is awesome"
+ });
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/req/setHeader.bru b/packages/bruno-tests/collection/scripting/api/req/setHeader.bru
new file mode 100644
index 000000000..be33894c5
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/setHeader.bru
@@ -0,0 +1,32 @@
+meta {
+ name: setHeader
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+headers {
+ bruno: is-awesome
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+script:pre-request {
+ req.setHeader('bruno', 'is-the-future');
+}
+
+tests {
+ test("req.setHeader(name)", function() {
+ const h = req.getHeader('bruno');
+ expect(h).to.equal("is-the-future");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/setHeaders.bru b/packages/bruno-tests/collection/scripting/api/req/setHeaders.bru
new file mode 100644
index 000000000..b4d9532dc
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/setHeaders.bru
@@ -0,0 +1,37 @@
+meta {
+ name: setHeaders
+ type: http
+ seq: 8
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+headers {
+ bruno: is-awesome
+ della: is-beautiful
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+script:pre-request {
+ req.setHeaders({
+ "content-type": "application/text",
+ "transaction-id": "foobar"
+ });
+}
+
+tests {
+ test("req.setHeaders()", function() {
+ const h = req.getHeaders();
+ expect(h['content-type']).to.equal("application/text");
+ expect(h['transaction-id']).to.equal("foobar");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/setMethod.bru b/packages/bruno-tests/collection/scripting/api/req/setMethod.bru
new file mode 100644
index 000000000..45aa435bb
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/setMethod.bru
@@ -0,0 +1,28 @@
+meta {
+ name: setMethod
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+script:pre-request {
+ req.setMethod("GET");
+}
+
+tests {
+ test("req.setMethod()()", function() {
+ const method = req.getMethod();
+ expect(method).to.equal("GET");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/req/setUrl.bru b/packages/bruno-tests/collection/scripting/api/req/setUrl.bru
new file mode 100644
index 000000000..a0c429690
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/req/setUrl.bru
@@ -0,0 +1,28 @@
+meta {
+ name: setUrl
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping/invalid
+ body: none
+ auth: none
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+script:pre-request {
+ req.setUrl("https://testbench-sanity.usebruno.com/ping");
+}
+
+tests {
+ test("req.setUrl()", function() {
+ const url = req.getUrl();
+ expect(url).to.equal("https://testbench-sanity.usebruno.com/ping");
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/res/getBody.bru b/packages/bruno-tests/collection/scripting/api/res/getBody.bru
new file mode 100644
index 000000000..521d36a01
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getBody.bru
@@ -0,0 +1,40 @@
+meta {
+ name: getBody
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("res.getBody()", function() {
+ const data = res.getBody();
+ expect(data).to.eql({
+ "hello": "bruno"
+ });
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/getHeader.bru b/packages/bruno-tests/collection/scripting/api/res/getHeader.bru
new file mode 100644
index 000000000..1ab640726
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getHeader.bru
@@ -0,0 +1,38 @@
+meta {
+ name: getHeader
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("res.getHeader(name)", function() {
+ const server = res.getHeader('x-powered-by');
+ expect(server).to.eql('Express');
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/getHeaders.bru b/packages/bruno-tests/collection/scripting/api/res/getHeaders.bru
new file mode 100644
index 000000000..58dc7495d
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getHeaders.bru
@@ -0,0 +1,39 @@
+meta {
+ name: getHeaders
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("res.getHeaders(name)", function() {
+ const h = res.getHeaders();
+ expect(h['x-powered-by']).to.eql('Express');
+ expect(h['content-length']).to.eql('17');
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/getResponseTime.bru b/packages/bruno-tests/collection/scripting/api/res/getResponseTime.bru
new file mode 100644
index 000000000..236a5eff1
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getResponseTime.bru
@@ -0,0 +1,39 @@
+meta {
+ name: getResponseTime
+ type: http
+ seq: 5
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+auth:basic {
+ username: asd
+ password: j
+}
+
+auth:bearer {
+ token:
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+tests {
+ test("res.getResponseTime()", function() {
+ const responseTime = res.getResponseTime();
+ expect(typeof responseTime).to.eql("number");
+ expect(responseTime > 0).to.be.true;
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/getStatus.bru b/packages/bruno-tests/collection/scripting/api/res/getStatus.bru
new file mode 100644
index 000000000..3c511754c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getStatus.bru
@@ -0,0 +1,24 @@
+meta {
+ name: getStatus
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+
+assert {
+ res.status: eq 200
+ res.body: eq pong
+}
+
+tests {
+ test("res.getStatus()", function() {
+ const status = res.getStatus()
+ expect(status).to.equal(200);
+ });
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/collection/scripting/api/res/getStatusText.bru b/packages/bruno-tests/collection/scripting/api/res/getStatusText.bru
new file mode 100644
index 000000000..f023d217c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/getStatusText.bru
@@ -0,0 +1,23 @@
+meta {
+ name: getStatusText
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+assert {
+ res.statusText: eq OK
+ res.body: eq pong
+}
+
+tests {
+ test("res.getStatusText()", function() {
+ const statusText = res.getStatusText()
+ expect(statusText).to.equal('OK');
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/array.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/array.bru
new file mode 100644
index 000000000..7e3492abe
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/array.bru
@@ -0,0 +1,45 @@
+meta {
+ name: array
+ type: http
+ seq: 6
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ const obj = {
+ hello : "hello from post-res"
+ }
+ // Safe mode, Dev mode behaves differently, null is getting converted to undefined, although both have null in the response, tests with undefined fails in safe mode, this needs to be investigated,, undefined is not a valid JSON
+ res.setBody(["hello",1, null, undefined, true, obj])
+}
+
+tests {
+ test("res.setBody(array)", function() {
+ const body = res.getBody();
+ expect(body.length).to.eql(6);
+ expect(body[0]).to.eql("hello")
+ expect(body[1]).to.eql(1)
+ expect(body[2]).to.be.null
+ // Safe mode, Dev mode behaves differently, null is getting converted to undefined, although both have null in the response, tests with undefined fails in safe mode, this needs to be investigated,, undefined is not a valid JSON
+ expect(body[3]).to.be.undefined;
+ expect(body[4]).to.eql(true)
+ expect(body[5].hello).to.eql("hello from post-res")
+
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/boolean.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/boolean.bru
new file mode 100644
index 000000000..28513d679
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/boolean.bru
@@ -0,0 +1,33 @@
+meta {
+ name: boolean
+ type: http
+ seq: 7
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ res.setBody(true)
+}
+
+tests {
+ test("res.setBody(boolean)", function() {
+ const body = res.getBody();
+ expect(body).to.be.true;
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/folder.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/folder.bru
new file mode 100644
index 000000000..4e66417c7
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/folder.bru
@@ -0,0 +1,3 @@
+meta {
+ name: setBody
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/null.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/null.bru
new file mode 100644
index 000000000..8d8d2dcf5
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/null.bru
@@ -0,0 +1,33 @@
+meta {
+ name: null
+ type: http
+ seq: 6
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ res.setBody(null)
+}
+
+tests {
+ test("res.setBody(null)", function() {
+ const body = res.getBody();
+ expect(body).to.be.null;
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/number.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/number.bru
new file mode 100644
index 000000000..a90aec802
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/number.bru
@@ -0,0 +1,33 @@
+meta {
+ name: number
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ res.setBody(2)
+}
+
+tests {
+ test("res.setBody(number)", function() {
+ const body = res.getBody();
+ expect(body).to.eql(2);
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/object.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/object.bru
new file mode 100644
index 000000000..02ad78a2e
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/object.bru
@@ -0,0 +1,35 @@
+meta {
+ name: object
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ res.setBody({
+ hello : "hello from post-res"
+ })
+}
+
+tests {
+ test("res.setBody(object)", function() {
+ const body = res.getBody();
+ expect(body.hello).to.eql("hello from post-res");
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/string.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/string.bru
new file mode 100644
index 000000000..a87e57bd4
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/string.bru
@@ -0,0 +1,33 @@
+meta {
+ name: string
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ res.setBody("hello from post-res")
+}
+
+tests {
+ test("res.setBody(string)", function() {
+ const body = res.getBody();
+ expect(body).to.eql("hello from post-res");
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/undefined.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/undefined.bru
new file mode 100644
index 000000000..0dc918f1f
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/res/setBody/undefined.bru
@@ -0,0 +1,36 @@
+meta {
+ name: undefined
+ type: http
+ seq: 7
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "hello": "bruno"
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:post-response {
+ // if undefined is not passed to res.setBody() the test fails in only safe-mode, needs to check, undefined is not a valid JSON
+ // Safe mode, Dev mode behaves differently, null is getting converted to undefined, although both have null in the response, tests with undefined fails in safe mode, this needs to be investigated, undefined is not a valid JSON
+ res.setBody(undefined)
+}
+
+tests {
+ test("res.setBody(undefined)", function() {
+ const body = res.getBody();
+ // Safe mode, Dev mode behaves differently, null is getting converted to undefined, although both have null in the response, tests with undefined fails in safe mode, this needs to be investigated, undefined is not a valid JSON
+ expect(body).to.be.undefined;
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/get-env-name.bru b/packages/bruno-tests/collection/scripting/get-env-name.bru
deleted file mode 100644
index bce2d1973..000000000
--- a/packages/bruno-tests/collection/scripting/get-env-name.bru
+++ /dev/null
@@ -1,54 +0,0 @@
-meta {
- name: get-env-name
- type: http
- seq: 1
-}
-
-get {
- url: {{host}}/ping
- body: none
- auth: none
-}
-
-auth:awsv4 {
- accessKeyId: a
- secretAccessKey: b
- sessionToken: c
- service: d
- region: e
- profileName: f
-}
-
-script:pre-request {
- const envName = bru.getEnvName();
- bru.setVar("testEnvName", envName);
-}
-
-tests {
- test("should get env name in scripts", function() {
- const testEnvName = bru.getVar("testEnvName");
- expect(testEnvName).to.equal("Prod");
- });
-}
-
-docs {
- # API Documentation
-
- ## Introduction
-
- Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
-
- ## Authentication
-
- Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
-
- ### API Key
-
- To use API key authentication, include your API key in the request headers as follows:
-
- ```http
- GET /api/endpoint
- Host: api.example.com
- Authorization: Bearer YOUR_API_KEY
-
-}
diff --git a/packages/bruno-tests/collection/scripting/get-env-var.bru b/packages/bruno-tests/collection/scripting/get-env-var.bru
deleted file mode 100644
index 5c9d8ec5d..000000000
--- a/packages/bruno-tests/collection/scripting/get-env-var.bru
+++ /dev/null
@@ -1,49 +0,0 @@
-meta {
- name: get-env-var
- type: http
- seq: 2
-}
-
-get {
- url: {{host}}/ping
- body: none
- auth: none
-}
-
-auth:awsv4 {
- accessKeyId: a
- secretAccessKey: b
- sessionToken: c
- service: d
- region: e
- profileName: f
-}
-
-tests {
- test("should get env var in scripts", function() {
- const host = bru.getEnvVar("host")
- expect(host).to.equal("https://testbench-sanity.usebruno.com");
- });
-}
-
-docs {
- # API Documentation
-
- ## Introduction
-
- Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
-
- ## Authentication
-
- Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
-
- ### API Key
-
- To use API key authentication, include your API key in the request headers as follows:
-
- ```http
- GET /api/endpoint
- Host: api.example.com
- Authorization: Bearer YOUR_API_KEY
-
-}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/axios/axios-pre-req-script.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/axios/axios-pre-req-script.bru
new file mode 100644
index 000000000..1998c9665
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/axios/axios-pre-req-script.bru
@@ -0,0 +1,34 @@
+meta {
+ name: axios-pre-req-script
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ const axios = require("axios");
+
+ const url = "https://testbench-sanity.usebruno.com/api/echo/json";
+ const response = await axios.post(url, {
+ "hello": "bruno"
+ });
+
+ req.setBody(response.data);
+ req.setMethod("POST");
+ req.setUrl(url);
+}
+
+tests {
+ test("req.getBody()", function() {
+ const data = res.getBody();
+ expect(data).to.eql({
+ "hello": "bruno"
+ });
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/cheerio/cheerio.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/cheerio/cheerio.bru
new file mode 100644
index 000000000..ce7a6346c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/cheerio/cheerio.bru
@@ -0,0 +1,59 @@
+meta {
+ name: cheerio
+ type: http
+ seq: 1
+}
+
+post {
+ url: https://echo.usebruno.com
+ body: text
+ auth: none
+}
+
+body:text {
+ Hello Bruno!
+}
+
+script:pre-request {
+ const cheerio = require('cheerio');
+
+ const $ = cheerio.load('Hello world ');
+
+ $('h2.title').text('Hello pre-request!');
+ $('h2').addClass('welcome');
+
+ bru.setVar("cheerio-test-pre-request", $.html());
+}
+
+script:post-response {
+ const cheerio = require('cheerio');
+
+ const $ = cheerio.load('Hello world ');
+
+ $('h2.title').text('Hello post-response!');
+ $('h2').addClass('welcome');
+
+ bru.setVar("cheerio-test-post-response", $.html());
+}
+
+tests {
+ const cheerio = require('cheerio');
+
+ test("cheerio html - from pre request script", function() {
+ const expected = 'Hello pre-request! ';
+ const html = bru.getVar('cheerio-test-pre-request');
+ expect(html).to.eql(expected);
+ });
+
+ test("cheerio html - from post response script", function() {
+ const expected = 'Hello post-response! ';
+ const html = bru.getVar('cheerio-test-post-response');
+ expect(html).to.eql(expected);
+ });
+
+ test("cheerio html - from tests", function() {
+ const expected = 'Hello Bruno! ';
+ const $ = cheerio.load(res.body);
+ expect($.html()).to.eql(expected);
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru
new file mode 100644
index 000000000..8385847c9
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/crypto-js/crypto-js-pre-request-script.bru
@@ -0,0 +1,33 @@
+meta {
+ name: crypto-js-pre-request-script
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ var CryptoJS = require("crypto-js");
+
+ // Encrypt
+ var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123').toString();
+
+ // Decrypt
+ var bytes = CryptoJS.AES.decrypt(ciphertext, 'secret key 123');
+ var originalText = bytes.toString(CryptoJS.enc.Utf8);
+
+ bru.setVar('crypto-test-message', originalText);
+}
+
+tests {
+ test("crypto message", function() {
+ const data = bru.getVar('crypto-test-message');
+ bru.setVar('crypto-test-message', null);
+ expect(data).to.eql('my message');
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/nanoid/nanoid.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/nanoid/nanoid.bru
new file mode 100644
index 000000000..14aa35172
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/nanoid/nanoid.bru
@@ -0,0 +1,26 @@
+meta {
+ name: nanoid
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ const { nanoid } = require("nanoid");
+
+ bru.setVar("nanoid-test-id", nanoid());
+}
+
+tests {
+ test("nanoid var", function() {
+ const id = bru.getVar('nanoid-test-id');
+ let isValidNanoid = /^[a-zA-Z0-9_-]{21}$/.test(id)
+ bru.setVar('nanoid-test-id', null);
+ expect(isValidNanoid).to.eql(true);
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/folder.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/folder.bru
new file mode 100644
index 000000000..4fd4bb1d2
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/folder.bru
@@ -0,0 +1,3 @@
+meta {
+ name: tv4
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/tv4.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/tv4.bru
new file mode 100644
index 000000000..820a7a3b8
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/tv4/tv4.bru
@@ -0,0 +1,39 @@
+meta {
+ name: tv4
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: inherit
+}
+
+body:json {
+ {
+ "name": "John",
+ "age": 30
+ }
+}
+
+tests {
+ const tv4 = require("tv4")
+
+ const schema = {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ age: { type: 'number' }
+ }
+ };
+
+ let responseData = res.getBody();
+
+ let isValid = tv4.validate(responseData, schema);
+
+ test("Response body matches expected schema", function () {
+ expect(isValid, tv4.error ? tv4.error.message : "").to.be.true;
+ });
+
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/uuid/uuid.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/uuid/uuid.bru
new file mode 100644
index 000000000..ba0c2edb5
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/uuid/uuid.bru
@@ -0,0 +1,26 @@
+meta {
+ name: uuid
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ const { v4 } = require("uuid");
+
+ bru.setVar("uuid-test-id", v4());
+}
+
+tests {
+ test("uuid var", function() {
+ const id = bru.getVar('uuid-test-id');
+ let isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
+ bru.setVar('uuid-test-id', null);
+ expect(isValidUuid).to.eql(true);
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/inbuilt modules/xml2js/xml2js.bru b/packages/bruno-tests/collection/scripting/inbuilt modules/xml2js/xml2js.bru
new file mode 100644
index 000000000..935263117
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/inbuilt modules/xml2js/xml2js.bru
@@ -0,0 +1,57 @@
+meta {
+ name: xml2js
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ var parseString = require('xml2js').parseString;
+ var xml = "Hello xml2js - pre request! "
+ parseString(xml, function (err, result) {
+ bru.setVar("xml2js-test-result-pre-request", result);
+ });
+}
+
+script:post-response {
+ var parseString = require('xml2js').parseString;
+ var xml = "Hello xml2js - post response! "
+ parseString(xml, function (err, result) {
+ bru.setVar("xml2js-test-result-post-response", result);
+ });
+}
+
+tests {
+ var parseString = require('xml2js').parseString;
+
+ test("xml2js parseString in scripts - pre request", function() {
+ const expected = {
+ root: 'Hello xml2js - pre request!'
+ };
+ const result = bru.getVar('xml2js-test-result-pre-request');
+ expect(result).to.eql(expected);
+ });
+
+ test("xml2js parseString in scripts - post response", function() {
+ const expected = {
+ root: 'Hello xml2js - post response!'
+ };
+ const result = bru.getVar('xml2js-test-result-post-response');
+ expect(result).to.eql(expected);
+ });
+
+ test("xml2js parseString in tests", async function() {
+ var xml = "Hello inside test! "
+ const expected = {
+ root: 'Hello inside test!'
+ };
+ parseString(xml, function (err, result) {
+ expect(result).to.eql(expected);
+ });
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/js/data types - request vars.bru b/packages/bruno-tests/collection/scripting/js/data types - request vars.bru
new file mode 100644
index 000000000..a0f7c91a8
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/data types - request vars.bru
@@ -0,0 +1,44 @@
+meta {
+ name: data types - request vars
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "boolean": false,
+ "number_1": 1,
+ "number_2": 0,
+ "number_3": -1,
+ "string": "bruno",
+ "array": [1, 2, 3, 4, 5],
+ "object": {
+ "hello": "bruno"
+ },
+ "null": null
+ }
+}
+
+assert {
+ req.body.boolean: isBoolean false
+ req.body.number_1: isNumber 1
+ req.body.undefined: isUndefined undefined
+ req.body.string: isString bruno
+ req.body.null: isNull null
+ req.body.array: isArray
+ req.body.boolean: eq false
+ req.body.number_1: eq 1
+ req.body.undefined: eq undefined
+ req.body.string: eq bruno
+ req.body.null: eq null
+ req.body.number_2: eq 0
+ req.body.number_3: eq -1
+ req.body.number_2: isNumber
+ req.body.number_3: isNumber
+}
diff --git a/packages/bruno-tests/collection/scripting/js/data types.bru b/packages/bruno-tests/collection/scripting/js/data types.bru
new file mode 100644
index 000000000..a08c68b8f
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/data types.bru
@@ -0,0 +1,54 @@
+meta {
+ name: data types
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "boolean": false,
+ "number": 1,
+ "string": "bruno",
+ "array": [1, 2, 3, 4, 5],
+ "object": {
+ "hello": "bruno"
+ },
+ "null": null
+ }
+}
+
+script:pre-request {
+ const reqBody = req.getBody();
+
+ bru.setVar("dataTypeVarTest", {
+ ...reqBody,
+ "undefined": undefined
+ });
+}
+
+tests {
+ test("data types check via bru var", function() {
+ let v = bru.getVar("dataTypeVarTest");
+ v = {
+ ...v,
+ "undefined": undefined
+ };
+ expect(v).to.eql({
+ "boolean": false,
+ "number": 1,
+ "string": "bruno",
+ "array": [1, 2, 3, 4, 5],
+ "object": {
+ "hello": "bruno"
+ },
+ "null": null,
+ "undefined": undefined
+ })
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/js/folder-collection script-tests pre.bru b/packages/bruno-tests/collection/scripting/js/folder-collection script-tests pre.bru
new file mode 100644
index 000000000..52911a98d
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/folder-collection script-tests pre.bru
@@ -0,0 +1,16 @@
+meta {
+ name: folder-collection script-tests pre
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{echo-host}}
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ bru.setVar('should-test-collection-scripts', true);
+ bru.setVar('should-test-folder-scripts', true);
+}
diff --git a/packages/bruno-tests/collection/scripting/js/folder-collection script-tests.bru b/packages/bruno-tests/collection/scripting/js/folder-collection script-tests.bru
new file mode 100644
index 000000000..7a892104f
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/folder-collection script-tests.bru
@@ -0,0 +1,28 @@
+meta {
+ name: folder-collection script-tests
+ type: http
+ seq: 5
+}
+
+post {
+ url: {{echo-host}}
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ // do not delete - the collection/folder scripts/tests run during this request execution
+}
+
+tests {
+ const collectionHeader = req.getHeader("collection-header");
+ const folderHeader = req.getHeader("folder-header");
+
+ test("should get the header value set at collection level", function() {
+ expect(collectionHeader).to.equal("collection-header-value");
+ });
+
+ test("should get the header value set at folder level", function() {
+ expect(folderHeader).to.equal("folder-header-value");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/js/folder.bru b/packages/bruno-tests/collection/scripting/js/folder.bru
new file mode 100644
index 000000000..bab33e1ba
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/folder.bru
@@ -0,0 +1,28 @@
+meta {
+ name: js
+}
+
+headers {
+ folder-header: folder-header-value
+}
+
+script:pre-request {
+ // used by `scripting/js/folder-collection script-tests`
+ const shouldTestFolderScripts = bru.getVar('should-test-folder-scripts');
+ if(shouldTestFolderScripts) {
+ bru.setVar('folder-var-set-by-folder-script', 'folder-var-value-set-by-folder-script');
+ }
+}
+
+tests {
+ // used by `scripting/js/folder-collection script-tests`
+ const shouldTestFolderScripts = bru.getVar('should-test-folder-scripts');
+ const folderVar = bru.getVar("folder-var-set-by-folder-script");
+ if (shouldTestFolderScripts && folderVar) {
+ test("folder level test - should get the var that was set by the folder script", function() {
+ expect(folderVar).to.equal("folder-var-value-set-by-folder-script");
+ });
+ bru.setVar('folder-var-set-by-folder-script', null);
+ bru.setVar('should-test-folder-scripts', null);
+ }
+}
diff --git a/packages/bruno-tests/collection/scripting/js/setTimeout.bru b/packages/bruno-tests/collection/scripting/js/setTimeout.bru
new file mode 100644
index 000000000..8b136a113
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/js/setTimeout.bru
@@ -0,0 +1,32 @@
+meta {
+ name: setTimeout
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ bru.setVar("test-js-set-timeout", "");
+ await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ bru.setVar("test-js-set-timeout", "bruno");
+ resolve();
+ }, 1000);
+ });
+
+ const v = bru.getVar("test-js-set-timeout");
+ bru.setVar("test-js-set-timeout", v + "-is-awesome");
+
+}
+
+tests {
+ test("setTimeout()", function() {
+ const v = bru.getVar("test-js-set-timeout")
+ expect(v).to.eql("bruno-is-awesome");
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/local modules/invalid and valid module imports.bru b/packages/bruno-tests/collection/scripting/local modules/invalid and valid module imports.bru
new file mode 100644
index 000000000..89c3ad23d
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/local modules/invalid and valid module imports.bru
@@ -0,0 +1,37 @@
+meta {
+ name: invalid and valid module imports
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+assert {
+ invalid_module_error_thrown: eq true
+ valid_module_no_error: eq true
+}
+
+script:pre-request {
+ try {
+ bru.setVar('invalid_module_error_thrown', false);
+ // should throw an error
+ const invalid = require("./lib/invalid");
+ }
+ catch(error) {
+ bru.setVar('invalid_module_error_thrown', true);
+ }
+
+
+ try {
+ bru.setVar('valid_module_no_error', true);
+ // should not throw an error
+ const math = require("./lib/math");
+ }
+ catch(error) {
+ bru.setVar('valid_module_no_error', false);
+ }
+}
diff --git a/packages/bruno-tests/collection/scripting/local modules/sum (without js extn).bru b/packages/bruno-tests/collection/scripting/local modules/sum (without js extn).bru
new file mode 100644
index 000000000..819d61c56
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/local modules/sum (without js extn).bru
@@ -0,0 +1,45 @@
+meta {
+ name: sum (without js extn)
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "a": 1,
+ "b": 2
+ }
+}
+
+assert {
+ res.status: eq 200
+}
+
+script:pre-request {
+ const math = require("./lib/math");
+ console.log(math, 'math');
+
+ const body = req.getBody();
+ body.sum = math.sum(body.a, body.b);
+ body.areaOfCircle = math.areaOfCircle(2);
+
+ req.setBody(body);
+}
+
+tests {
+ test("should return json", function() {
+ const data = res.getBody();
+ expect(res.getBody()).to.eql({
+ "a": 1,
+ "b": 2,
+ "sum": 3,
+ "areaOfCircle": 12.56
+ });
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/local modules/sum.bru b/packages/bruno-tests/collection/scripting/local modules/sum.bru
index c0c9a1aeb..eeccab181 100644
--- a/packages/bruno-tests/collection/scripting/local modules/sum.bru
+++ b/packages/bruno-tests/collection/scripting/local modules/sum.bru
@@ -22,10 +22,9 @@ assert {
}
script:pre-request {
- const math = require("./lib/math");
-
+ const math = require("./lib/math.js");
const body = req.getBody();
- body.sum = body.a + body.b;
+ body.sum = math.sum(body.a, body.b);
req.setBody(body);
}
@@ -39,4 +38,22 @@ tests {
"sum": 3
});
});
+
+ test("should return json", function() {
+ const data = res.getBody();
+ expect(res.getBody()).to.eql({
+ "a": 1,
+ "b": 2,
+ "sum": 3
+ });
+ });
+
+ test("should return json", function() {
+ const data = res.getBody();
+ expect(res.getBody()).to.eql({
+ "a": 1,
+ "b": 2,
+ "sum": 3
+ });
+ });
}
diff --git a/packages/bruno-tests/collection/scripting/set-env-var.bru b/packages/bruno-tests/collection/scripting/set-env-var.bru
deleted file mode 100644
index f193dbe88..000000000
--- a/packages/bruno-tests/collection/scripting/set-env-var.bru
+++ /dev/null
@@ -1,54 +0,0 @@
-meta {
- name: set-env-var
- type: http
- seq: 3
-}
-
-get {
- url: {{host}}/ping
- body: none
- auth: none
-}
-
-auth:awsv4 {
- accessKeyId: a
- secretAccessKey: b
- sessionToken: c
- service: d
- region: e
- profileName: f
-}
-
-script:post-response {
- bru.setEnvVar("testSetEnvVar", "bruno-29653")
-}
-
-tests {
- test("should set env var in scripts", function() {
- const testSetEnvVar = bru.getEnvVar("testSetEnvVar")
- console.log(testSetEnvVar);
- expect(testSetEnvVar).to.equal("bruno-29653");
- });
-}
-
-docs {
- # API Documentation
-
- ## Introduction
-
- Welcome to the API documentation for [Your API Name]. This document provides instructions on how to make requests to the API and covers available authentication methods.
-
- ## Authentication
-
- Before making requests to the API, you need to authenticate your application. [Your API Name] supports the following authentication methods:
-
- ### API Key
-
- To use API key authentication, include your API key in the request headers as follows:
-
- ```http
- GET /api/endpoint
- Host: api.example.com
- Authorization: Bearer YOUR_API_KEY
-
-}
diff --git a/packages/bruno-tests/collection/string interpolation/folder.bru b/packages/bruno-tests/collection/string interpolation/folder.bru
new file mode 100644
index 000000000..8aef24cb2
--- /dev/null
+++ b/packages/bruno-tests/collection/string interpolation/folder.bru
@@ -0,0 +1,8 @@
+meta {
+ name: string interpolation
+}
+
+vars:pre-request {
+ folder_pre_var: folder_pre_var_value
+ folder_pre_var_2: {{env.var1}}
+}
diff --git a/packages/bruno-tests/collection/string interpolation/objects-arrays interpolation.bru b/packages/bruno-tests/collection/string interpolation/objects-arrays interpolation.bru
new file mode 100644
index 000000000..3c47c9d32
--- /dev/null
+++ b/packages/bruno-tests/collection/string interpolation/objects-arrays interpolation.bru
@@ -0,0 +1,81 @@
+meta {
+ name: objects/arrays interpolation
+ type: http
+ seq: 5
+}
+
+post {
+ url: https://echo.usebruno.com
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "undefined": "{{obj.undefined}}",
+ "null": {{obj.null}},
+ "number": {{obj.number}},
+ "boolean": {{obj.boolean}},
+ "array": {{arr}},
+ "array[0]": {{arr[0]}},
+ "object": {{obj}},
+ "object.foo": {{obj.foo}},
+ "object.foo.bar": {{obj.foo.bar}},
+ "object.foo.bar.baz": {{obj.foo.bar.baz}}
+ }
+}
+
+script:pre-request {
+ bru.setVar("arr", [1,2,3,4,5]);
+
+ bru.setVar("obj", {
+ "null": null,
+ "number": 1,
+ "boolean": true,
+ "foo": {
+ "bar": {
+ "baz": 1
+ }
+ }
+ });
+}
+
+tests {
+ test("should interpolate arrays and objects in request payload body", () => {
+ const resBody = res.getBody();
+ const expectedOutput = {
+ "undefined": "{{obj.undefined}}",
+ "null": null,
+ "number": 1,
+ "boolean": true,
+ "array": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ],
+ "array[0]": 1,
+ "object": {
+ "null": null,
+ "number": 1,
+ "boolean": true,
+ "foo": {
+ "bar": {
+ "baz": 1
+ }
+ }
+ },
+ "object.foo": {
+ "bar": {
+ "baz": 1
+ }
+ },
+ "object.foo.bar": {
+ "baz": 1
+ },
+ "object.foo.bar.baz": 1
+ };
+ expect(resBody).to.be.eql(expectedOutput);
+ })
+}
diff --git a/packages/bruno-tests/collection/string interpolation/runtime vars.bru b/packages/bruno-tests/collection/string interpolation/runtime vars.bru
index 6cda713e8..3bcdef9e9 100644
--- a/packages/bruno-tests/collection/string interpolation/runtime vars.bru
+++ b/packages/bruno-tests/collection/string interpolation/runtime vars.bru
@@ -30,7 +30,7 @@ body:text {
Hi, I am {{rUser.full_name}},
I am {{rUser.age}} years old.
My favorite food is {{rUser.fav-food[0]}} and {{rUser.fav-food[1]}}.
- I like attention: {{rUser.want.attention}}
+ I like attention: {{rUser['want.attention']}}
}
assert {
@@ -40,7 +40,7 @@ assert {
script:pre-request {
bru.setVar("rUser", {
full_name: 'Bruno',
- age: 4,
+ age: 5,
'fav-food': ['egg', 'meat'],
'want.attention': true
});
@@ -49,7 +49,7 @@ script:pre-request {
tests {
test("should return json", function() {
const expectedResponse = `Hi, I am Bruno,
- I am 4 years old.
+ I am 5 years old.
My favorite food is egg and meat.
I like attention: true`;
expect(res.getBody()).to.equal(expectedResponse);
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/keycloak-authorization_code/bruno.json b/packages/bruno-tests/keycloak-authorization_code/bruno.json
new file mode 100644
index 000000000..fce93eac9
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "keycloak-authorization_code",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/keycloak-authorization_code/collection.bru b/packages/bruno-tests/keycloak-authorization_code/collection.bru
new file mode 100644
index 000000000..c858dac93
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/collection.bru
@@ -0,0 +1,22 @@
+auth {
+ mode: oauth2
+}
+
+auth:oauth2 {
+ grant_type: authorization_code
+ callback_url: {{key-host}}/realms/bruno/account
+ authorization_url: {{key-host}}/realms/bruno/protocol/openid-connect/auth
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ client_id: account
+ client_secret: {{client_secret}}
+ scope: openid
+ state:
+ pkce: true
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: false
+}
diff --git a/packages/bruno-tests/keycloak-authorization_code/environments/oauth2.bru b/packages/bruno-tests/keycloak-authorization_code/environments/oauth2.bru
new file mode 100644
index 000000000..8d4ce79a8
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/environments/oauth2.bru
@@ -0,0 +1,6 @@
+vars {
+ key-host: http://localhost:8080
+}
+vars:secret [
+ client_secret
+]
diff --git a/packages/bruno-tests/keycloak-authorization_code/user_info_coll-auth.bru b/packages/bruno-tests/keycloak-authorization_code/user_info_coll-auth.bru
new file mode 100644
index 000000000..ec838c9fa
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/user_info_coll-auth.bru
@@ -0,0 +1,11 @@
+meta {
+ name: user_info_coll-auth
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: inherit
+}
diff --git a/packages/bruno-tests/keycloak-authorization_code/user_info_custom.bru b/packages/bruno-tests/keycloak-authorization_code/user_info_custom.bru
new file mode 100644
index 000000000..c5a757ed0
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/user_info_custom.bru
@@ -0,0 +1,15 @@
+meta {
+ name: user_info_custom
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: bearer
+}
+
+auth:bearer {
+ token: {{$oauth2.credentials.access_token}}
+}
diff --git a/packages/bruno-tests/keycloak-authorization_code/user_info_request-auth.bru b/packages/bruno-tests/keycloak-authorization_code/user_info_request-auth.bru
new file mode 100644
index 000000000..eabd03b54
--- /dev/null
+++ b/packages/bruno-tests/keycloak-authorization_code/user_info_request-auth.bru
@@ -0,0 +1,30 @@
+meta {
+ name: user_info_request-auth
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: json
+ auth: oauth2
+}
+
+auth:oauth2 {
+ grant_type: authorization_code
+ callback_url: {{key-host}}/realms/bruno/account
+ authorization_url: {{key-host}}/realms/bruno/protocol/openid-connect/auth
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ client_id: account
+ client_secret: {{client_secret}}
+ scope: openid
+ state:
+ pkce: true
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: true
+}
diff --git a/packages/bruno-tests/keycloak-client-credentials/bruno.json b/packages/bruno-tests/keycloak-client-credentials/bruno.json
new file mode 100644
index 000000000..783e37474
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "keycloak-client-credentials",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/keycloak-client-credentials/collection.bru b/packages/bruno-tests/keycloak-client-credentials/collection.bru
new file mode 100644
index 000000000..e488de865
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/collection.bru
@@ -0,0 +1,18 @@
+auth {
+ mode: oauth2
+}
+
+auth:oauth2 {
+ grant_type: client_credentials
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ client_id: account
+ client_secret: {{client_secret}}
+ scope: openid
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: false
+}
diff --git a/packages/bruno-tests/keycloak-client-credentials/environments/oauth2.bru b/packages/bruno-tests/keycloak-client-credentials/environments/oauth2.bru
new file mode 100644
index 000000000..8d4ce79a8
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/environments/oauth2.bru
@@ -0,0 +1,6 @@
+vars {
+ key-host: http://localhost:8080
+}
+vars:secret [
+ client_secret
+]
diff --git a/packages/bruno-tests/keycloak-client-credentials/user_info_coll-auth.bru b/packages/bruno-tests/keycloak-client-credentials/user_info_coll-auth.bru
new file mode 100644
index 000000000..ec838c9fa
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/user_info_coll-auth.bru
@@ -0,0 +1,11 @@
+meta {
+ name: user_info_coll-auth
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: inherit
+}
diff --git a/packages/bruno-tests/keycloak-client-credentials/user_info_custom.bru b/packages/bruno-tests/keycloak-client-credentials/user_info_custom.bru
new file mode 100644
index 000000000..c5a757ed0
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/user_info_custom.bru
@@ -0,0 +1,15 @@
+meta {
+ name: user_info_custom
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: bearer
+}
+
+auth:bearer {
+ token: {{$oauth2.credentials.access_token}}
+}
diff --git a/packages/bruno-tests/keycloak-client-credentials/user_info_request-auth.bru b/packages/bruno-tests/keycloak-client-credentials/user_info_request-auth.bru
new file mode 100644
index 000000000..a8a69792b
--- /dev/null
+++ b/packages/bruno-tests/keycloak-client-credentials/user_info_request-auth.bru
@@ -0,0 +1,26 @@
+meta {
+ name: user_info_request-auth
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: oauth2
+}
+
+auth:oauth2 {
+ grant_type: client_credentials
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ client_id: account
+ client_secret: {{client_secret}}a
+ scope: openid
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: false
+}
diff --git a/packages/bruno-tests/keycloak-password-credentials/bruno.json b/packages/bruno-tests/keycloak-password-credentials/bruno.json
new file mode 100644
index 000000000..495eacff7
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "keycloak-password-credentials",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/keycloak-password-credentials/collection.bru b/packages/bruno-tests/keycloak-password-credentials/collection.bru
new file mode 100644
index 000000000..0bcc69e2d
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/collection.bru
@@ -0,0 +1,20 @@
+auth {
+ mode: oauth2
+}
+
+auth:oauth2 {
+ grant_type: password
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ username: bruno
+ password: bruno
+ client_id: account
+ client_secret: {{client_secret}}
+ scope: openid
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: false
+}
diff --git a/packages/bruno-tests/keycloak-password-credentials/environments/oauth2.bru b/packages/bruno-tests/keycloak-password-credentials/environments/oauth2.bru
new file mode 100644
index 000000000..8d4ce79a8
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/environments/oauth2.bru
@@ -0,0 +1,6 @@
+vars {
+ key-host: http://localhost:8080
+}
+vars:secret [
+ client_secret
+]
diff --git a/packages/bruno-tests/keycloak-password-credentials/user_info_coll-auth.bru b/packages/bruno-tests/keycloak-password-credentials/user_info_coll-auth.bru
new file mode 100644
index 000000000..ec838c9fa
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/user_info_coll-auth.bru
@@ -0,0 +1,11 @@
+meta {
+ name: user_info_coll-auth
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: inherit
+}
diff --git a/packages/bruno-tests/keycloak-password-credentials/user_info_custom.bru b/packages/bruno-tests/keycloak-password-credentials/user_info_custom.bru
new file mode 100644
index 000000000..c5a757ed0
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/user_info_custom.bru
@@ -0,0 +1,15 @@
+meta {
+ name: user_info_custom
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: bearer
+}
+
+auth:bearer {
+ token: {{$oauth2.credentials.access_token}}
+}
diff --git a/packages/bruno-tests/keycloak-password-credentials/user_info_request-auth.bru b/packages/bruno-tests/keycloak-password-credentials/user_info_request-auth.bru
new file mode 100644
index 000000000..c17b6cf0f
--- /dev/null
+++ b/packages/bruno-tests/keycloak-password-credentials/user_info_request-auth.bru
@@ -0,0 +1,28 @@
+meta {
+ name: user_info_request-auth
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{key-host}}/realms/bruno/protocol/openid-connect/userinfo
+ body: none
+ auth: oauth2
+}
+
+auth:oauth2 {
+ grant_type: password
+ access_token_url: {{key-host}}/realms/bruno/protocol/openid-connect/token
+ refresh_token_url:
+ username: admin
+ password: admin
+ client_id: account
+ client_secret: {{client_secret}}
+ scope: openid
+ credentials_placement: body
+ credentials_id: credentials
+ token_placement: header
+ token_header_prefix: Bearer
+ auto_fetch_token: true
+ auto_refresh_token: false
+}
diff --git a/packages/bruno-tests/package.json b/packages/bruno-tests/package.json
index 84ede3d62..75cfde1ae 100644
--- a/packages/bruno-tests/package.json
+++ b/packages/bruno-tests/package.json
@@ -18,13 +18,13 @@
},
"homepage": "https://github.com/usebruno/bruno-testbench#readme",
"dependencies": {
- "axios": "^1.5.1",
- "body-parser": "^1.20.0",
+ "axios": "^1.8.3",
+ "body-parser": "1.20.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
- "express": "^4.18.1",
+ "express": "^4.21.2",
"express-basic-auth": "^1.2.1",
- "express-xml-bodyparser": "^0.3.0",
+ "fast-xml-parser": "^5.0.8",
"http-proxy": "^1.18.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
diff --git a/packages/bruno-tests/sandwich_exec/bruno.json b/packages/bruno-tests/sandwich_exec/bruno.json
new file mode 100644
index 000000000..07f81bd1e
--- /dev/null
+++ b/packages/bruno-tests/sandwich_exec/bruno.json
@@ -0,0 +1,12 @@
+{
+ "version": "1",
+ "name": "sandwich_exec",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ],
+ "scripts": {
+ "flow": "sandwich"
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/sandwich_exec/collection.bru b/packages/bruno-tests/sandwich_exec/collection.bru
new file mode 100644
index 000000000..5dd99c114
--- /dev/null
+++ b/packages/bruno-tests/sandwich_exec/collection.bru
@@ -0,0 +1,13 @@
+script:pre-request {
+ console.log("collection pre");
+}
+
+script:post-response {
+ {
+ console.log("collection post");
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(1);
+ bru.setVar('sequence', sequence);
+ console.log("sequence", bru.getVar('sequence'));
+ }
+}
diff --git a/packages/bruno-tests/sandwich_exec/folder/folder.bru b/packages/bruno-tests/sandwich_exec/folder/folder.bru
new file mode 100644
index 000000000..d2f5aedaa
--- /dev/null
+++ b/packages/bruno-tests/sandwich_exec/folder/folder.bru
@@ -0,0 +1,16 @@
+meta {
+ name: folder
+}
+
+script:pre-request {
+ console.log("folder pre");
+}
+
+script:post-response {
+ {
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(2);
+ bru.setVar('sequence', sequence);
+ }
+ console.log("folder post");
+}
diff --git a/packages/bruno-tests/sandwich_exec/folder/request.bru b/packages/bruno-tests/sandwich_exec/folder/request.bru
new file mode 100644
index 000000000..59c20fb99
--- /dev/null
+++ b/packages/bruno-tests/sandwich_exec/folder/request.bru
@@ -0,0 +1,33 @@
+meta {
+ name: request
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://www.example.com
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ console.log("request pre");
+}
+
+script:post-response {
+ {
+ console.log("request post");
+
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(3);
+ bru.setVar('sequence', sequence);
+ }
+}
+
+tests {
+ test("sandwich script execution is proper", function() {
+ const sequence = bru.getVar('sequence');
+ bru.setVar('sequence', null);
+ expect(sequence.toString()).to.equal([3,2,1].toString());
+ });
+}
diff --git a/packages/bruno-tests/sequential_exec/bruno.json b/packages/bruno-tests/sequential_exec/bruno.json
new file mode 100644
index 000000000..dd2870831
--- /dev/null
+++ b/packages/bruno-tests/sequential_exec/bruno.json
@@ -0,0 +1,12 @@
+{
+ "version": "1",
+ "name": "sequential_exec",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ],
+ "scripts": {
+ "flow": "sequential"
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/sequential_exec/collection.bru b/packages/bruno-tests/sequential_exec/collection.bru
new file mode 100644
index 000000000..76c9c2289
--- /dev/null
+++ b/packages/bruno-tests/sequential_exec/collection.bru
@@ -0,0 +1,12 @@
+script:pre-request {
+ console.log("collection pre");
+}
+
+script:post-response {
+ {
+ console.log("collection post");
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(1);
+ bru.setVar('sequence', sequence);
+ }
+}
diff --git a/packages/bruno-tests/sequential_exec/folder/folder.bru b/packages/bruno-tests/sequential_exec/folder/folder.bru
new file mode 100644
index 000000000..50abd3032
--- /dev/null
+++ b/packages/bruno-tests/sequential_exec/folder/folder.bru
@@ -0,0 +1,16 @@
+meta {
+ name: folder
+}
+
+script:pre-request {
+ console.log("folder pre");
+}
+
+script:post-response {
+ {
+ console.log("folder post");
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(2);
+ bru.setVar('sequence', sequence);
+ }
+}
diff --git a/packages/bruno-tests/sequential_exec/folder/request.bru b/packages/bruno-tests/sequential_exec/folder/request.bru
new file mode 100644
index 000000000..1d9a0c528
--- /dev/null
+++ b/packages/bruno-tests/sequential_exec/folder/request.bru
@@ -0,0 +1,34 @@
+meta {
+ name: request
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://www.example.com
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ console.log("request pre");
+}
+
+script:post-response {
+ {
+ console.log("request post");
+ const sequence = bru.getVar('sequence') || [];
+ sequence.push(3);
+ bru.setVar('sequence', sequence);
+
+ console.log("sequence", bru.getVar('sequence'));
+ }
+}
+
+tests {
+ test("sequential script execution is proper", function() {
+ const sequence = bru.getVar('sequence');
+ bru.setVar('sequence', null);
+ expect(sequence.toString()).to.equal([1,2,3].toString());
+ });
+}
diff --git a/packages/bruno-tests/src/auth/index.js b/packages/bruno-tests/src/auth/index.js
index 6d6ebfb55..e26a65529 100644
--- a/packages/bruno-tests/src/auth/index.js
+++ b/packages/bruno-tests/src/auth/index.js
@@ -3,6 +3,7 @@ const router = express.Router();
const authBearer = require('./bearer');
const authBasic = require('./basic');
+const authWsse = require('./wsse');
const authCookie = require('./cookie');
const authOAuth2PasswordCredentials = require('./oauth2/passwordCredentials');
const authOAuth2AuthorizationCode = require('./oauth2/authorizationCode');
@@ -13,6 +14,7 @@ router.use('/oauth2/authorization_code', authOAuth2AuthorizationCode);
router.use('/oauth2/client_credentials', authOAuth2ClientCredentials);
router.use('/bearer', authBearer);
router.use('/basic', authBasic);
+router.use('/wsse', authWsse);
router.use('/cookie', authCookie);
module.exports = router;
diff --git a/packages/bruno-tests/src/auth/wsse.js b/packages/bruno-tests/src/auth/wsse.js
new file mode 100644
index 000000000..1af574a3d
--- /dev/null
+++ b/packages/bruno-tests/src/auth/wsse.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const express = require('express');
+const router = express.Router();
+const crypto = require('crypto');
+
+function sha256(data) {
+ return crypto.createHash('sha256').update(data).digest('base64');
+}
+
+function validateWSSE(req, res, next) {
+ const wsseHeader = req.headers['x-wsse'];
+ if (!wsseHeader) {
+ return unauthorized(res, 'WSSE header is missing');
+ }
+
+ const regex = /UsernameToken Username="(.+?)", PasswordDigest="(.+?)", (?:Nonce|nonce)="(.+?)", Created="(.+?)"/;
+ const matches = wsseHeader.match(regex);
+
+ if (!matches) {
+ return unauthorized(res, 'Invalid WSSE header format');
+ }
+
+ const [_, username, passwordDigest, nonce, created] = matches;
+ const expectedPassword = 'bruno'; // Ideally store in a config or env variable
+ const expectedDigest = sha256(nonce + created + expectedPassword);
+
+ if (passwordDigest !== expectedDigest) {
+ return unauthorized(res, 'Invalid credentials');
+ }
+
+ next();
+}
+
+// Helper to respond with an unauthorized SOAP fault
+function unauthorized(res, message) {
+ const faultResponse = `
+
+
+
+
+ soapenv:Client
+ ${message}
+
+
+
+ `;
+ res.status(401).set('Content-Type', 'text/xml');
+ res.send(faultResponse);
+}
+
+const responses = {
+ success: `
+
+
+
+
+ Success
+
+
+
+ `
+};
+
+router.post('/protected', validateWSSE, (req, res) => {
+ res.set('Content-Type', 'text/xml');
+ res.send(responses.success);
+});
+
+module.exports = router;
diff --git a/packages/bruno-tests/src/echo/index.js b/packages/bruno-tests/src/echo/index.js
index 2a2b52eb3..00b50bd36 100644
--- a/packages/bruno-tests/src/echo/index.js
+++ b/packages/bruno-tests/src/echo/index.js
@@ -19,6 +19,17 @@ router.post('/xml-raw', (req, res) => {
return res.send(req.rawBody);
});
+router.post('/bin', (req, res) => {
+ const rawBody = req.body;
+
+ if (!rawBody || rawBody.length === 0) {
+ return res.status(400).send('No data received');
+ }
+
+ res.set('Content-Type', req.headers['content-type'] || 'application/octet-stream');
+ res.send(rawBody);
+});
+
router.get('/bom-json-test', (req, res) => {
const jsonData = {
message: 'Hello!',
@@ -31,4 +42,36 @@ router.get('/bom-json-test', (req, res) => {
return res.send(jsonWithBom);
});
+router.get('/iso-enc', (req, res) => {
+ res.set('Content-Type', 'text/plain; charset=ISO-8859-1');
+ const responseText = 'éçà';
+ return res.send(Buffer.from(responseText, 'latin1'));
+});
+
+router.post("/custom", (req, res) => {
+ const { headers, content, contentBase64, contentJSON, type } = req.body || {};
+
+ res._headers = {};
+
+ if (type) {
+ res.setHeader('Content-Type', type);
+ }
+
+ if (headers && typeof headers === 'object') {
+ Object.entries(headers).forEach(([key, value]) => {
+ res.setHeader(key, value);
+ });
+ }
+
+ if (contentBase64) {
+ res.write(Buffer.from(contentBase64, 'base64'));
+ } else if (contentJSON !== undefined) {
+ res.write(JSON.stringify(contentJSON));
+ } else if (content !== undefined) {
+ res.write(content);
+ }
+
+ return res.end();
+});
+
module.exports = router;
diff --git a/packages/bruno-tests/src/index.js b/packages/bruno-tests/src/index.js
index 9ba6e3170..735bd929b 100644
--- a/packages/bruno-tests/src/index.js
+++ b/packages/bruno-tests/src/index.js
@@ -1,24 +1,33 @@
const express = require('express');
const bodyParser = require('body-parser');
-const xmlparser = require('express-xml-bodyparser');
const cors = require('cors');
-const multer = require('multer');
-
-const app = new express();
-const port = process.env.PORT || 8080;
-const upload = multer();
-
-app.use(cors());
-app.use(xmlparser());
-app.use(bodyParser.text());
-app.use(bodyParser.json());
-app.use(bodyParser.urlencoded({ extended: true }));
-
+const formDataParser = require('./multipart/form-data-parser');
const authRouter = require('./auth');
const echoRouter = require('./echo');
+const xmlParser = require('./utils/xmlParser');
+const multipartRouter = require('./multipart');
+
+const app = new express();
+const port = process.env.PORT || 8081;
+
+app.use(cors());
+
+const saveRawBody = (req, res, buf) => {
+ req.rawBuffer = Buffer.from(buf);
+ req.rawBody = buf.toString();
+};
+
+app.use(bodyParser.json({ verify: saveRawBody }));
+app.use(bodyParser.urlencoded({ extended: true, verify: saveRawBody }));
+app.use(bodyParser.text({ verify: saveRawBody }));
+app.use(xmlParser());
+app.use(express.raw({ type: '*/*', limit: '100mb', verify: saveRawBody }));
+
+formDataParser.init(app, express);
app.use('/api/auth', authRouter);
app.use('/api/echo', echoRouter);
+app.use('/api/multipart', multipartRouter);
app.get('/ping', function (req, res) {
return res.send('pong');
@@ -32,10 +41,6 @@ app.get('/query', function (req, res) {
return res.json(req.query);
});
-app.post('/echo/multipartForm', upload.none(), function (req, res) {
- return res.json(req.body);
-});
-
app.get('/redirect-to-ping', function (req, res) {
return res.redirect('/ping');
});
diff --git a/packages/bruno-tests/src/multipart/form-data-parser.js b/packages/bruno-tests/src/multipart/form-data-parser.js
new file mode 100644
index 000000000..8b4aa500a
--- /dev/null
+++ b/packages/bruno-tests/src/multipart/form-data-parser.js
@@ -0,0 +1,58 @@
+/**
+ * Instead of using multer for example to parse the multipart form data, we build our own parser
+ * so that we can verify the content type are set correctly by bruno (for example application/json for json content)
+ */
+
+const extractParam = function (param, str, delimiter, quote, endDelimiter) {
+ let regex = new RegExp(`${param}${delimiter}\\s*${quote}(.*?)${quote}${endDelimiter}`);
+ const found = str.match(regex);
+ if (found != null && found.length > 1) {
+ return found[1];
+ } else {
+ return null;
+ }
+};
+
+const init = function (app, express) {
+ app.use(express.raw({ type: 'multipart/form-data' }));
+};
+
+const parsePart = function (part) {
+ let result = {};
+ const name = extractParam('name', part, '=', '"', '');
+ if (name) {
+ result.name = name;
+ }
+ const filename = extractParam('filename', part, '=', '"', '');
+ if (filename) {
+ result.filename = filename;
+ }
+ const contentType = extractParam('Content-Type', part, ':', '', ';');
+ if (contentType) {
+ result.contentType = contentType;
+ }
+ if (!filename) {
+ result.value = part.substring(part.indexOf('value=') + 'value='.length);
+ }
+ if (contentType === 'application/json') {
+ result.value = JSON.parse(result.value);
+ }
+ return result;
+};
+
+const parse = function (req) {
+ const BOUNDARY = 'boundary=';
+ const contentType = req.headers['content-type'];
+ const boundary = '--' + contentType.substring(contentType.indexOf(BOUNDARY) + BOUNDARY.length);
+ const rawBody = req.body.toString();
+ let parts = rawBody.split(boundary).filter((part) => part.length > 0);
+ parts = parts.map((part) => part.trim('\r\n'));
+ parts = parts.filter((part) => part != '--');
+ parts = parts.map((part) => part.replace('\r\n\r\n', ';value='));
+ parts = parts.map((part) => part.replace('\r\n', ';'));
+ parts = parts.map((part) => parsePart(part));
+ return parts;
+};
+
+module.exports.parse = parse;
+module.exports.init = init;
diff --git a/packages/bruno-tests/src/multipart/index.js b/packages/bruno-tests/src/multipart/index.js
new file mode 100644
index 000000000..a98837c54
--- /dev/null
+++ b/packages/bruno-tests/src/multipart/index.js
@@ -0,0 +1,10 @@
+const express = require('express');
+const router = express.Router();
+const formDataParser = require('./form-data-parser');
+
+router.post('/mixed-content-types', (req, res) => {
+ const parts = formDataParser.parse(req);
+ return res.json(parts);
+});
+
+module.exports = router;
diff --git a/packages/bruno-tests/src/utils/xmlParser.js b/packages/bruno-tests/src/utils/xmlParser.js
new file mode 100644
index 000000000..ed765c077
--- /dev/null
+++ b/packages/bruno-tests/src/utils/xmlParser.js
@@ -0,0 +1,30 @@
+const { XMLParser } = require('fast-xml-parser');
+
+const xmlParser = () => {
+ const parser = new XMLParser({
+ ignoreAttributes: false,
+ allowBooleanAttributes: true,
+ });
+
+ return (req, res, next) => {
+ if (req.is('application/xml') || req.is('text/xml')) {
+ let data = '';
+ req.setEncoding('utf8');
+ req.on('data', (chunk) => {
+ data += chunk;
+ });
+ req.on('end', () => {
+ try {
+ req.body = parser.parse(data);
+ next();
+ } catch (err) {
+ res.status(400).send('Invalid XML');
+ }
+ });
+ } else {
+ next();
+ }
+ };
+};
+
+module.exports = xmlParser;
\ No newline at end of file
diff --git a/playwright.config.js b/playwright.config.js
deleted file mode 100644
index 63afd88d4..000000000
--- a/playwright.config.js
+++ /dev/null
@@ -1,110 +0,0 @@
-// @ts-check
-const { devices } = require('@playwright/test');
-
-/**
- * Read environment variables from file.
- * https://github.com/motdotla/dotenv
- */
-// require('dotenv').config();
-
-process.env.PLAYWRIGHT = "1";
-
-/**
- * @see https://playwright.dev/docs/test-configuration
- * @type {import('@playwright/test').PlaywrightTestConfig}
- */
-const config = {
- testDir: './tests',
- /* Maximum time one test can run for. */
- timeout: 30 * 1000,
- expect: {
- /**
- * Maximum time expect() should wait for the condition to be met.
- * For example in `await expect(locator).toHaveText();`
- */
- timeout: 5000
- },
- /* Run tests in files in parallel */
- fullyParallel: true,
- /* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: !!process.env.CI,
- /* Retry on CI only */
- retries: process.env.CI ? 2 : 0,
- /* Opt out of parallel tests on CI. */
- workers: process.env.CI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: 'html',
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
- use: {
- /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
- actionTimeout: 0,
- /* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: 'http://localhost:3000',
-
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
- trace: 'retain-on-failure',
- },
-
- /* Configure projects for major browsers */
- projects: [
- {
- name: 'chromium',
- use: {
- ...devices['Desktop Chrome'],
- },
- },
-
- {
- name: 'firefox',
- use: {
- ...devices['Desktop Firefox'],
- },
- },
-
- {
- name: 'webkit',
- use: {
- ...devices['Desktop Safari'],
- },
- },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: {
- // ...devices['Pixel 5'],
- // },
- // },
- // {
- // name: 'Mobile Safari',
- // use: {
- // ...devices['iPhone 12'],
- // },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: {
- // channel: 'msedge',
- // },
- // },
- // {
- // name: 'Google Chrome',
- // use: {
- // channel: 'chrome',
- // },
- // },
- ],
-
- /* Folder for test artifacts such as screenshots, videos, traces, etc. */
- // outputDir: 'test-results/',
-
- /* Run your local dev server before starting the tests */
- webServer: {
- command: 'npm run dev:web',
- port: 3000,
- },
-};
-
-module.exports = config;
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 000000000..df01a2ec8
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,39 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const reporter: any[] = [['list'], ['html']];
+
+if (process.env.CI) {
+ reporter.push(['github']);
+}
+
+export default defineConfig({
+ testDir: './e2e-tests',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ workers: process.env.CI ? undefined : 1,
+ reporter,
+
+ use: {
+ trace: process.env.CI ? 'on-first-retry' : 'on'
+ },
+
+ projects: [
+ {
+ name: 'Bruno Electron App'
+ }
+ ],
+
+ webServer: [
+ {
+ command: 'npm run dev:web',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI
+ },
+ {
+ command: 'npm start --workspace=packages/bruno-tests',
+ url: 'http://localhost:8081/ping',
+ reuseExistingServer: !process.env.CI
+ }
+ ]
+});
diff --git a/playwright/codegen.ts b/playwright/codegen.ts
new file mode 100644
index 000000000..da2bfcb1f
--- /dev/null
+++ b/playwright/codegen.ts
@@ -0,0 +1,13 @@
+const path = require('path');
+const { startApp } = require('./electron.ts');
+
+async function main() {
+ const { app, context } = await startApp();
+ let outputFile = process.argv[2]?.trim();
+ if (outputFile && !/\.(ts|js)$/.test(outputFile)) {
+ outputFile = path.join(__dirname, '../e2e-tests/', outputFile + '.spec.ts');
+ }
+ await context._enableRecorder({ language: 'playwright-test', mode: 'recording', outputFile });
+}
+
+main();
diff --git a/playwright/electron.ts b/playwright/electron.ts
new file mode 100644
index 000000000..4363f46e0
--- /dev/null
+++ b/playwright/electron.ts
@@ -0,0 +1,17 @@
+const path = require('path');
+const { _electron: electron } = require('playwright');
+
+const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
+
+exports.startApp = async () => {
+ const app = await electron.launch({ args: [electronAppPath] });
+ const context = await app.context();
+
+ app.process().stdout.on('data', (data) => {
+ process.stdout.write(data.toString().replace(/^(?=.)/gm, '[Electron] |'));
+ });
+ app.process().stderr.on('data', (error) => {
+ process.stderr.write(error.toString().replace(/^(?=.)/gm, '[Electron] |'));
+ });
+ return { app, context };
+};
diff --git a/playwright/index.ts b/playwright/index.ts
new file mode 100644
index 000000000..549086326
--- /dev/null
+++ b/playwright/index.ts
@@ -0,0 +1,179 @@
+import { test as baseTest, BrowserContext, ElectronApplication, Page } from '@playwright/test';
+import * as path from 'path';
+import * as os from 'os';
+import * as fs from 'fs';
+
+const electronAppPath = path.join(__dirname, '../packages/bruno-electron');
+
+export const test = baseTest.extend<
+ {
+ context: BrowserContext;
+ page: Page;
+ newPage: Page;
+ pageWithUserData: Page;
+ },
+ {
+ createTmpDir: (tag?: string) => Promise;
+ launchElectronApp: (options?: { initUserDataPath?: string }) => Promise;
+ electronApp: ElectronApplication;
+ reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise;
+ }
+>({
+ createTmpDir: [
+ async ({}, use) => {
+ const dirs: string[] = [];
+ await use(async (tag?: string) => {
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `pw-${tag || ''}-`));
+ dirs.push(dir);
+ return dir;
+ });
+ await Promise.all(
+ dirs.map((dir) => fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 10 }).catch((e) => e))
+ );
+ },
+ { scope: 'worker' }
+ ],
+
+ launchElectronApp: [
+ async ({ playwright, createTmpDir }, use, workerInfo) => {
+ const apps: ElectronApplication[] = [];
+ await use(async ({ initUserDataPath } = {}) => {
+ const userDataPath = await createTmpDir('electron-userdata');
+
+ if (initUserDataPath) {
+ const replacements = {
+ projectRoot: path.join(__dirname, '..')
+ };
+
+ for (const file of await fs.promises.readdir(initUserDataPath)) {
+ let content = await fs.promises.readFile(path.join(initUserDataPath, file), 'utf-8');
+ content = content.replace(/{{(\w+)}}/g, (_, key) => {
+ if (replacements[key]) {
+ return replacements[key];
+ } else {
+ throw new Error(`\tNo replacement for {{${key}}} in ${path.join(initUserDataPath, file)}`);
+ }
+ });
+ await fs.promises.writeFile(path.join(userDataPath, file), content, 'utf-8');
+ }
+ }
+
+ const app = await playwright._electron.launch({
+ args: [electronAppPath],
+ env: {
+ ...process.env,
+ ELECTRON_USER_DATA_PATH: userDataPath,
+ }
+ });
+
+ const { workerIndex } = workerInfo;
+ app.process().stdout.on('data', (data) => {
+ process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
+ });
+ app.process().stderr.on('data', (error) => {
+ process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
+ });
+
+ apps.push(app);
+ return app;
+ });
+ for (const app of apps) {
+ await app.context().close();
+ await app.close();
+ }
+ },
+ { scope: 'worker' }
+ ],
+
+ electronApp: [
+ async ({ launchElectronApp }, use) => {
+ const app = await launchElectronApp();
+ await use(app);
+ },
+ { scope: 'worker' }
+ ],
+
+ context: async ({ electronApp }, use, testInfo) => {
+ const context = await electronApp.context();
+ const tracingOptions = (testInfo as any)._tracing.traceOptions();
+ if (tracingOptions) {
+ try {
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
+ } catch (e) {}
+ }
+ await use(context);
+ },
+
+ page: async ({ electronApp, context }, use, testInfo) => {
+ const page = await electronApp.firstWindow();
+ const tracingOptions = (testInfo as any)._tracing.traceOptions();
+ if (tracingOptions) {
+ const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
+ await context.tracing.startChunk();
+ await use(page);
+ await context.tracing.stopChunk({ path: tracePath });
+ await testInfo.attach('trace', { path: tracePath });
+ } else {
+ await use(page);
+ }
+ },
+
+ newPage: async ({ launchElectronApp }, use, testInfo) => {
+ const app = await launchElectronApp();
+ const context = await app.context();
+ const page = await app.firstWindow();
+ const tracingOptions = (testInfo as any)._tracing.traceOptions();
+ if (tracingOptions) {
+ const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
+ await use(page);
+ await context.tracing.stop({ path: tracePath });
+ await testInfo.attach('trace', { path: tracePath });
+ } else {
+ await use(page);
+ }
+ },
+
+ reuseOrLaunchElectronApp: [
+ async ({ launchElectronApp }, use, testInfo) => {
+ const apps: Record = {};
+ await use(async ({ initUserDataPath } = {}) => {
+ const key = initUserDataPath;
+ if (key && apps[key]) {
+ return apps[key];
+ }
+ const app = await launchElectronApp({ initUserDataPath });
+ apps[key] = app;
+ return app;
+ });
+ },
+ { scope: 'worker' }
+ ],
+
+ pageWithUserData: async ({ reuseOrLaunchElectronApp }, use, testInfo) => {
+ const testDir = path.dirname(testInfo.file);
+ const initUserDataPath = path.join(testDir, 'init-user-data');
+
+ const app = await reuseOrLaunchElectronApp(
+ (await fs.promises.stat(initUserDataPath).catch(() => false)) ? { initUserDataPath } : {}
+ );
+
+ const context = await app.context();
+ const page = await app.firstWindow();
+ const tracingOptions = (testInfo as any)._tracing.traceOptions();
+ if (tracingOptions) {
+ const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
+ try {
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
+ } catch (e) {}
+ await context.tracing.startChunk();
+ await use(page);
+ await context.tracing.stopChunk({ path: tracePath });
+ await testInfo.attach('trace', { path: tracePath });
+ } else {
+ await use(page);
+ }
+ }
+});
+
+export * from '@playwright/test';
diff --git a/publishing.md b/publishing.md
index c95d48f92..458077b20 100644
--- a/publishing.md
+++ b/publishing.md
@@ -1,4 +1,15 @@
-**English** | [Português (BR)](docs/publishing/publishing_pt_br.md) | [Română](docs/publishing/publishing_ro.md) | [Polski](docs/publishing/publishing_pl.md) | [বাংলা](docs/publishing/publishing_bn.md) | [Français](docs/publishing/publishing_fr.md) | [正體中文](docs/publishing/publishing_zhtw.md) |[ 简体中文](docs/publishing/publishing_cn.md) |[日本語](docs/publishing/publishing_ja.md) | [Deutsch](docs/publishing/publishing_de.md)
+**English**
+| [Türkçe](docs/publishing/publishing_tr.md)
+| [Deutsch](docs/publishing/publishing_de.md)
+| [Français](docs/publishing/publishing_fr.md)
+| [Português (BR)](docs/publishing/publishing_pt_br.md)
+| [বাংলা](docs/publishing/publishing_bn.md)
+| [Română](docs/publishing/publishing_ro.md)
+| [Polski](docs/publishing/publishing_pl.md)
+| [简体中文](docs/publishing/publishing_cn.md)
+| [正體中文](docs/publishing/publishing_zhtw.md)
+| [日本語](docs/publishing/publishing_ja.md)
+| [Nederlands](docs/publishing/publishing_nl.md)
### Publishing Bruno to a new package manager
diff --git a/readme.md b/readme.md
index 3e6248bdc..ed37afc1f 100644
--- a/readme.md
+++ b/readme.md
@@ -10,7 +10,25 @@
[](https://www.usebruno.com)
[](https://www.usebruno.com/downloads)
-**English** | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | [Français](docs/readme/readme_fr.md) | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md) | [العربية](docs/readme/readme_ar.md) | [日本語](docs/readme/readme_ja.md)
+**English**
+| [Українська](docs/readme/readme_ua.md)
+| [Русский](docs/readme/readme_ru.md)
+| [Türkçe](docs/readme/readme_tr.md)
+| [Deutsch](docs/readme/readme_de.md)
+| [Français](docs/readme/readme_fr.md)
+| [Português (BR)](docs/readme/readme_pt_br.md)
+| [한국어](docs/readme/readme_kr.md)
+| [বাংলা](docs/readme/readme_bn.md)
+| [Español](docs/readme/readme_es.md)
+| [Italiano](docs/readme/readme_it.md)
+| [Română](docs/readme/readme_ro.md)
+| [Polski](docs/readme/readme_pl.md)
+| [简体中文](docs/readme/readme_cn.md)
+| [正體中文](docs/readme/readme_zhtw.md)
+| [العربية](docs/readme/readme_ar.md)
+| [日本語](docs/readme/readme_ja.md)
+| [ქართული](docs/readme/readme_ka.md)
+| [Nederlands](docs/readme/readme_nl.md)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
@@ -26,14 +44,29 @@ Bruno is offline-only. There are no plans to add cloud-sync to Bruno, ever. We v

-### Golden Edition ✨
+## Commercial Versions ✨
Majority of our features are free and open source.
We strive to strike a harmonious balance between [open-source principles and sustainability](https://github.com/usebruno/bruno/discussions/269)
-You can buy the [Golden Edition](https://www.usebruno.com/pricing) for a one-time payment of **$19** !
+You can explore our [paid versions](https://www.usebruno.com/pricing) to see if there are additional features that you or your team may find useful!
-### Installation
+## Table of Contents
+- [Installation](#installation)
+- [Features](#features)
+ - [Run across multiple platforms 🖥️](#run-across-multiple-platforms-%EF%B8%8F)
+ - [Collaborate via Git 👩💻🧑💻](#collaborate-via-git-)
+- [Important Links 📌](#important-links-)
+- [Showcase 🎥](#showcase-)
+- [Share Testimonials 📣](#share-testimonials-)
+- [Publishing to New Package Managers](#publishing-to-new-package-managers)
+- [Stay in touch 🌐](#stay-in-touch-)
+- [Trademark](#trademark)
+- [Contribute 👩💻🧑💻](#contribute-)
+- [Authors](#authors)
+- [License 📄](#license-)
+
+## Installation
Bruno is available as binary download [on our website](https://www.usebruno.com/downloads) for Mac, Windows and Linux.
@@ -68,6 +101,8 @@ echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebr
sudo apt update && sudo apt install bruno
```
+## Features
+
### Run across multiple platforms 🖥️

@@ -78,59 +113,38 @@ Or any version control system of your choice

-### Sponsors
-
-#### Gold Sponsors
-
-
-
-#### Silver Sponsors
-
-
-
-#### Bronze Sponsors
-
-
-
-
-
-### Important Links 📌
+## Important Links 📌
- [Our Long Term Vision](https://github.com/usebruno/bruno/discussions/269)
-- [Roadmap](https://github.com/usebruno/bruno/discussions/384)
+- [Roadmap](https://www.usebruno.com/roadmap)
- [Documentation](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [Website](https://www.usebruno.com)
- [Pricing](https://www.usebruno.com/pricing)
- [Download](https://www.usebruno.com/downloads)
-- [GitHub Sponsors](https://github.com/sponsors/helloanoop).
-### Showcase 🎥
+## Showcase 🎥
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
-### Support ❤️
-
-If you like Bruno and want to support our opensource work, consider sponsoring us via [GitHub Sponsors](https://github.com/sponsors/helloanoop).
-
-### Share Testimonials 📣
+## Share Testimonials 📣
If Bruno has helped you at work and your teams, please don't forget to share your [testimonials on our GitHub discussion](https://github.com/usebruno/bruno/discussions/343)
-### Publishing to New Package Managers
+## Publishing to New Package Managers
Please see [here](publishing.md) for more information.
-### Stay in touch 🌐
+## Stay in touch 🌐
[𝕏 (Twitter)](https://twitter.com/use_bruno)
[Website](https://www.usebruno.com)
[Discord](https://discord.com/invite/KgcZUncpjq)
[LinkedIn](https://www.linkedin.com/company/usebruno)
-### Trademark
+## Trademark
**Name**
@@ -140,13 +154,13 @@ Please see [here](publishing.md) for more information.
The logo is sourced from [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
-### Contribute 👩💻🧑💻
+## Contribute 👩💻🧑💻
I am happy that you are looking to improve bruno. Please check out the [contributing guide](contributing.md)
Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case.
-### Authors
+## Authors
-### License 📄
+## License 📄
[MIT](license.md)
diff --git a/scripts/build-electron.js b/scripts/build-electron.js
index 9825c3a09..6d778ffdf 100644
--- a/scripts/build-electron.js
+++ b/scripts/build-electron.js
@@ -2,6 +2,7 @@ const os = require('os');
const fs = require('fs-extra');
const util = require('util');
const spawn = util.promisify(require('child_process').spawn);
+const path = require('path');
async function deleteFileIfExists(filePath) {
try {
@@ -78,18 +79,34 @@ async function main() {
console.log('The directory has been created successfully!');
// Copy build
- await copyFolderIfExists('packages/bruno-app/out', 'packages/bruno-electron/web');
+ await copyFolderIfExists('packages/bruno-app/dist', 'packages/bruno-electron/web');
- // Change paths in next
+ // Update static paths
const files = await fs.readdir('packages/bruno-electron/web');
for (const file of files) {
if (file.endsWith('.html')) {
let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8');
- content = content.replace(/\/_next\//g, '_next/');
+ content = content.replace(/\/static/g, './static');
await fs.writeFile(`packages/bruno-electron/web/${file}`, content);
}
}
+ // update font load paths
+ const cssDir = path.join('packages/bruno-electron/web/static/css');
+ try {
+ const cssFiles = await fs.readdir(cssDir);
+ for (const file of cssFiles) {
+ if (file.endsWith('.css')) {
+ const filePath = path.join(cssDir, file);
+ let content = await fs.readFile(filePath, 'utf8');
+ content = content.replace(/\/static\/font/g, '../../static/font');
+ await fs.writeFile(filePath, content);
+ }
+ }
+ } catch (error) {
+ console.error(`Error updating font paths: ${error}`);
+ }
+
// Remove sourcemaps
await removeSourceMapFiles('packages/bruno-electron/web');
diff --git a/scripts/build-electron.sh b/scripts/build-electron.sh
index 7afb3c545..7f3887c70 100755
--- a/scripts/build-electron.sh
+++ b/scripts/build-electron.sh
@@ -10,11 +10,12 @@ rm -rf packages/bruno-electron/web
mkdir packages/bruno-electron/web
# Copy build
-cp -r packages/bruno-app/out/* packages/bruno-electron/web
+cp -r packages/bruno-app/dist/* packages/bruno-electron/web
-# Change paths in next
-sed -i'' -e 's@/_next/@_next/@g' packages/bruno-electron/web/**.html
+# Update static paths
+sed -i'' -e 's@/static/@static/@g' packages/bruno-electron/web/**.html
+sed -i'' -e 's@/static/font@../../static/font@g' packages/bruno-electron/web/static/css/**.**.css
# Remove sourcemaps
find packages/bruno-electron/web -name '*.map' -type f -delete
diff --git a/scripts/dev-hot-reload.js b/scripts/dev-hot-reload.js
new file mode 100644
index 000000000..52440f279
--- /dev/null
+++ b/scripts/dev-hot-reload.js
@@ -0,0 +1,240 @@
+#!/usr/bin/env node
+
+/**
+# Bruno Development Script
+#
+# This script sets up and runs the Bruno development environment with hot-reloading.
+# It manages concurrent processes for various packages and provides cleanup on exit.
+#
+# Usage:
+# From the root of the project, run:
+# node ./scripts/dev-hot-reload.js [options]
+# or
+# npm run dev:watch -- [options]
+*/
+
+const { execSync } = require('child_process');
+const { readFileSync } = require('fs');
+
+// Get major version from .nvmrc (e.g. v22.1.0 -> v22)
+const NODE_VERSION = readFileSync('.nvmrc', 'utf8').trim().split('.')[0];
+
+// Configuration
+const CONFIG = {
+ NODE_VERSION,
+ ELECTRON_WATCH_PATHS: [
+ 'packages/**/dist/',
+ 'packages/bruno-electron/src/',
+ 'packages/bruno-lang/src/',
+ 'packages/bruno-lang/v2/src/',
+ 'packages/bruno-js/src/',
+ 'packages/bruno-schema/src/'
+ ],
+ ELECTRON_START_DELAY: 10, // seconds
+ NODEMON_WATCH_DELAY: 1000 // milliseconds
+};
+
+const COLORS = {
+ red: '\x1b[0;31m',
+ green: '\x1b[0;32m',
+ yellow: '\x1b[1;33m',
+ blue: '\x1b[0;34m',
+ nc: '\x1b[0m' // No Color
+};
+
+const LOG_LEVELS = {
+ INFO: 'INFO',
+ WARN: 'WARN',
+ ERROR: 'ERROR',
+ DEBUG: 'DEBUG',
+ SUCCESS: 'SUCCESS'
+};
+
+function log(level, msg) {
+ let color = COLORS.nc;
+ switch (level) {
+ case LOG_LEVELS.INFO:
+ case LOG_LEVELS.SUCCESS: color = COLORS.green; break;
+ case LOG_LEVELS.WARN: color = COLORS.yellow; break;
+ case LOG_LEVELS.ERROR: color = COLORS.red; break;
+ case LOG_LEVELS.DEBUG: color = COLORS.blue; break;
+ }
+
+ const output = `${color}[${level}]${COLORS.nc} ${msg}`;
+ if (level === LOG_LEVELS.ERROR) {
+ console.error(output);
+ } else {
+ console.log(output);
+ }
+}
+
+// Show help documentation
+function showHelp() {
+ console.log(`
+ Development Environment Setup for Bruno
+
+ Usage:
+ From the root of the project, run:
+ npm run dev:watch -- [options]
+ or
+ node scripts/dev-hot-reload.js [options]
+
+ Options:
+ -s, --setup Clean all node_modules folders and re-install dependencies before starting
+ -h, --help Show this help message
+
+ Examples:
+ # Start development environment
+ npm run dev:watch
+
+ # Start after cleaning node_modules
+ npm run dev:watch -- --setup
+
+ # Show this help
+ npm run dev:watch -- --help
+`);
+}
+
+function commandExists(command) {
+ try {
+ execSync(`command -v ${command}`, { stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// Install global NPM package if not present
+function ensureGlobalPackage(packageName) {
+ if (!commandExists(packageName)) {
+ log(LOG_LEVELS.INFO, `Installing ${packageName} globally...`);
+ execSync(`npm install -g ${packageName}`, { stdio: 'inherit' });
+ }
+}
+
+// Ensure correct node version
+function ensureNodeVersion(requiredVersion) {
+ const currentVersion = process.version;
+ if (!currentVersion.includes(requiredVersion)) {
+ log(LOG_LEVELS.ERROR, `Node ${requiredVersion} is required but currently installed version is ${currentVersion}`);
+ log(LOG_LEVELS.ERROR, `Please install node ${requiredVersion} and try again.`);
+ log(LOG_LEVELS.ERROR, `You can run 'nvm install ${requiredVersion}' to install it, or 'nvm use ${requiredVersion}' if it's already installed.`);
+
+ process.exit(1);
+ }
+}
+
+function cleanNodeModules() {
+ log(LOG_LEVELS.INFO, 'Removing all node_modules directories...');
+ execSync('find . -name "node_modules" -type d -prune -exec rm -rf {} +', { stdio: 'inherit' });
+ log(LOG_LEVELS.SUCCESS, 'Node modules cleanup completed');
+}
+
+function reinstallDependencies() {
+ log(LOG_LEVELS.INFO, 'Re-installing dependencies...');
+ execSync('npm install --legacy-peer-deps', { stdio: 'inherit' });
+ log(LOG_LEVELS.SUCCESS, 'Dependencies re-installation completed');
+}
+
+// Setup development environment
+function startDevelopment() {
+ log(LOG_LEVELS.INFO, 'Starting development servers...');
+
+ const concurrently = require('concurrently');
+ const watchPaths = CONFIG.ELECTRON_WATCH_PATHS.map(path => `--watch "${path}"`).join(' ');
+
+ // concurrently command objects: { command, name, prefixColor, env, cwd, ipc }
+ const commandObjects = [
+ {
+ command: 'npm run watch --workspace=packages/bruno-common',
+ name: 'common',
+ prefixColor: 'magenta'
+ },
+ {
+ command: 'npm run watch --workspace=packages/bruno-converters',
+ name: 'converters',
+ prefixColor: 'green'
+ },
+ {
+ command: 'npm run watch --workspace=packages/bruno-query',
+ name: 'query',
+ prefixColor: 'blue'
+ },
+ {
+ command: 'npm run watch --workspace=packages/bruno-graphql-docs',
+ name: 'graphql',
+ prefixColor: 'white'
+ },
+ {
+ command: 'npm run watch --workspace=packages/bruno-requests',
+ name: 'requests',
+ prefixColor: 'gray'
+ },
+ {
+ command: 'npm run dev:web',
+ name: 'react',
+ prefixColor: 'cyan'
+ },
+ {
+ command: `sleep ${CONFIG.ELECTRON_START_DELAY} && nodemon ${watchPaths} --ext js,jsx,ts,tsx --delay ${CONFIG.NODEMON_WATCH_DELAY}ms --exec "npm run dev --workspace=packages/bruno-electron"`,
+ name: 'electron',
+ prefixColor: 'yellow',
+ delay: CONFIG.ELECTRON_START_DELAY
+ }
+ ];
+
+ const { result } = concurrently(commandObjects, {
+ prefix: '[{name}: {pid}]',
+ killOthers: ['failure', 'success'],
+ restartTries: 3,
+ restartDelay: 1000
+ });
+
+ result
+ .then(() => log(LOG_LEVELS.SUCCESS, 'All processes completed successfully'))
+ .catch(err => {
+ log(LOG_LEVELS.ERROR, 'Development environment failed to start');
+ console.error(err);
+ process.exit(1);
+ });
+}
+
+// Main function
+(async function main() {
+ const args = process.argv.slice(2);
+ let runSetup = false;
+
+ // Parse command line arguments
+ for (const arg of args) {
+ if (arg === '-s' || arg === '--setup') {
+ runSetup = true;
+ } else if (arg === '-h' || arg === '--help') {
+ showHelp();
+ process.exit(0);
+ } else {
+ log(LOG_LEVELS.ERROR, `Unknown parameter: ${arg}`);
+ showHelp();
+ process.exit(1);
+ }
+ }
+
+ log(LOG_LEVELS.INFO, 'Initializing Bruno development environment...');
+
+ // Ensure required global packages and node version
+ ensureNodeVersion(CONFIG.NODE_VERSION);
+ ensureGlobalPackage('nodemon');
+ ensureGlobalPackage('concurrently');
+
+ // Run setup if requested
+ if (runSetup) {
+ cleanNodeModules();
+ reinstallDependencies();
+ }
+
+ // Start development environment
+ startDevelopment();
+})().catch(err => {
+ log(LOG_LEVELS.ERROR, 'An error occurred:');
+ console.error(err);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/scripts/setup.js b/scripts/setup.js
new file mode 100644
index 000000000..fc8b67a6b
--- /dev/null
+++ b/scripts/setup.js
@@ -0,0 +1,97 @@
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const icons = {
+ clean: '🧹',
+ delete: '🗑️',
+ install: '📦',
+ build: '🔨',
+ success: '✅',
+ error: '❌',
+ working: '⚡'
+};
+
+const execCommand = (command, description) => {
+ try {
+ console.log(`\n${icons.working} ${description}...`);
+ execSync(command, { stdio: 'inherit' });
+ console.log(`${icons.success} ${description} completed`);
+ } catch (error) {
+ console.error(`${icons.error} ${description} failed`);
+ throw error;
+ }
+};
+
+const glob = function (startPath, pattern) {
+ let results = [];
+
+ // Ensure start path exists
+ if (!fs.existsSync(startPath)) {
+ return results;
+ }
+
+ const files = fs.readdirSync(startPath);
+ for (const file of files) {
+ const filename = path.join(startPath, file);
+ const stat = fs.lstatSync(filename);
+
+ // If directory, recurse into it
+ if (stat.isDirectory()) {
+ // Skip node_modules recursion to avoid unnecessary deep scanning
+ if (file === 'node_modules') {
+ if (file === pattern) {
+ results.push(filename);
+ }
+ continue;
+ }
+ results = results.concat(glob(filename, pattern));
+ }
+
+ // If file matches pattern, add to results
+ if (file === pattern) {
+ results.push(filename);
+ }
+ }
+
+ return results;
+};
+
+async function setup() {
+ try {
+ // Clean up node_modules (if exists)
+ console.log(`\n${icons.clean} Cleaning up node_modules directories...`);
+ const nodeModulesPaths = glob('.', 'node_modules');
+ for (const dir of nodeModulesPaths) {
+ console.log(`${icons.delete} Removing ${dir}`);
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+
+ // Install dependencies
+ execCommand('npm i --legacy-peer-deps', 'Installing dependencies');
+
+ // Build packages
+ execCommand('npm run build:graphql-docs', 'Building graphql-docs');
+ execCommand('npm run build:bruno-query', 'Building bruno-query');
+ execCommand('npm run build:bruno-common', 'Building bruno-common');
+ execCommand('npm run build:bruno-converters', 'Building bruno-converters');
+ execCommand('npm run build:bruno-requests', 'Building bruno-requests');
+
+ // Bundle JS sandbox libraries
+ execCommand(
+ 'npm run sandbox:bundle-libraries --workspace=packages/bruno-js',
+ 'Bundling JS sandbox libraries'
+ );
+
+ console.log(`\n${icons.success} Setup completed successfully!\n`);
+ } catch (error) {
+ console.error(`\n${icons.error} Setup failed:`);
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+setup().catch(error => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/security.md b/security.md
new file mode 100644
index 000000000..32777f531
--- /dev/null
+++ b/security.md
@@ -0,0 +1,23 @@
+## Security
+
+At Bruno, we take security seriously and are committed to providing a safe experience for all users.
+We appreciate responsible disclosure and value contributions that help improve Bruno's security.
+
+
+## Reporting a Vulnerability
+
+To report a security issue, please email us at [security@usebruno.com](mailto:security@usebruno.com)
+
+When reporting a vulnerability, please include as many details as possible to help us investigate:
+
+- **Type of issue** (e.g., cross-site scripting, malicious npm package, etc.).
+- **Full paths of source file(s)** related to the issue.
+- **Location of affected code** (tag, branch, commit, or direct URL).
+- **Any special configuration** required to reproduce the issue.
+- **Step-by-step instructions** to reproduce the issue.
+- **Proof-of-concept or exploit code** (if available).
+- **Potential impact**, including how an attacker might exploit the issue.
+
+
+**Please do not report security vulnerabilities through public GitHub issues.**
+
diff --git a/tests/home.spec.js b/tests/home.spec.js
deleted file mode 100644
index 0ab8a5652..000000000
--- a/tests/home.spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const { test, expect } = require('@playwright/test');
-const { HomePage } = require('../tests/pages/home.page');
-const { faker } = require('./utils/data-faker');
-
-test.describe('bruno e2e test', () => {
- let homePage;
-
- test.beforeEach(async ({ page }) => {
- homePage = new HomePage(page);
-
- await homePage.open();
- await expect(page).toHaveURL('/');
- await expect(page).toHaveTitle(/bruno/);
- });
-
- test('user should be able to create new collection & new request', async () => {
- await homePage.createNewCollection(faker.randomWords);
- await expect(homePage.createNewCollectionSuccessToast).toBeVisible();
-
- // using fake data to simulate negative case
- await homePage.createNewRequest(faker.randomVerb, faker.randomHttpMethod, faker.randomUrl);
- await expect(homePage.networkErrorToast).toBeVisible();
-
- // using real data to simulate positive case
- await homePage.createNewRequest('Single User', 'GET', 'https://reqres.in/api/users/2');
- await expect(homePage.statusRequestSuccess).toBeVisible();
- });
-
- test('user should be able to load & use sample collection', async () => {
- await homePage.loadSampleCollection();
- await expect(homePage.loadSampleCollectionSuccessToast).toBeVisible();
-
- await homePage.getUsers();
- await expect(homePage.statusRequestSuccess).toBeVisible();
-
- await homePage.getSingleUser();
- await expect(homePage.statusRequestSuccess).toBeVisible();
-
- await homePage.getUserNotFound();
- await expect(homePage.statusRequestNotFound).toBeVisible();
-
- await homePage.createUser();
- await expect(homePage.statusRequestCreated).toBeVisible();
-
- await homePage.updateUser();
- await expect(homePage.statusRequestSuccess).toBeVisible();
- });
-});
diff --git a/tests/pages/home.page.js b/tests/pages/home.page.js
deleted file mode 100644
index 4aff24ce1..000000000
--- a/tests/pages/home.page.js
+++ /dev/null
@@ -1,86 +0,0 @@
-exports.HomePage = class HomePage {
- constructor(page) {
- this.page = page;
-
- // welcome
- this.createCollectionSelector = page.locator('#create-collection');
- this.addCollectionSelector = page.locator('#add-collection');
- this.importCollectionSelector = page.locator('#import-collection');
- this.loadSampleCollectionSelector = page.locator('#load-sample-collection');
-
- // sample collection
- this.loadSampleCollectionSuccessToast = page.getByText('Sample Collection loaded successfully');
- this.sampleCollectionSelector = page.locator('#sidebar-collection-name');
- this.getUsersSelector = page.getByText('Users');
- this.getSingleUserSelector = page.getByText('Single User');
- this.getUserNotFoundSelector = page.getByText('User Not Found');
- this.postCreateSelector = page.getByText('Create');
- this.putUpdateSelector = page.getByText('Update');
-
- // request panel
- this.sendRequestButton = page.locator('#send-request');
- this.statusRequestSuccess = page.getByText('200 OK');
- this.statusRequestNotFound = page.getByText('404 Not Found');
- this.statusRequestCreated = page.getByText('201 Created');
-
- // create collection
- this.collectionNameField = page.locator('#collection-name');
- this.submitButton = page.locator(`button[type='submit']`);
- this.createNewCollectionSuccessToast = page.getByText('Collection created');
- this.createNewTab = page.locator('#create-new-tab');
- this.requestNameField = page.locator('input[name="requestName"]');
- this.methodName = page.locator('#create-new-request-method').first();
- this.requestUrlField = page.locator('#request-url');
- this.networkErrorToast = page.getByText('Network Error');
- }
-
- async open() {
- await this.page.goto('/');
- }
-
- async loadSampleCollection() {
- await this.loadSampleCollectionSelector.click();
- }
-
- async getUsers() {
- await this.sampleCollectionSelector.click();
- await this.getUsersSelector.click();
- await this.sendRequestButton.click();
- }
-
- async getSingleUser() {
- await this.getSingleUserSelector.click();
- await this.sendRequestButton.click();
- }
-
- async getUserNotFound() {
- await this.getUserNotFoundSelector.click();
- await this.sendRequestButton.click();
- }
-
- async createUser() {
- await this.postCreateSelector.click();
- await this.sendRequestButton.click();
- }
-
- async updateUser() {
- await this.putUpdateSelector.click();
- await this.sendRequestButton.click();
- }
-
- async createNewCollection(collectionName) {
- await this.createCollectionSelector.click();
- await this.collectionNameField.fill(collectionName);
- await this.submitButton.click();
- }
-
- async createNewRequest(name, method, endpoint) {
- await this.createNewTab.click();
- await this.requestNameField.fill(name);
- await this.methodName.click();
- await this.page.click(`text=${method}`);
- await this.requestUrlField.fill(endpoint);
- await this.submitButton.click();
- await this.sendRequestButton.click();
- }
-};
diff --git a/tests/utils/data-faker.js b/tests/utils/data-faker.js
deleted file mode 100644
index 2674b6244..000000000
--- a/tests/utils/data-faker.js
+++ /dev/null
@@ -1,6 +0,0 @@
-const { faker } = require('@faker-js/faker');
-
-export let randomWords = faker.random.words();
-export let randomVerb = faker.hacker.verb();
-export let randomHttpMethod = faker.internet.httpMethod();
-export let randomUrl = faker.internet.url();