diff --git a/assets/images/landing-2.png b/assets/images/landing-2.png
index 156d207e5..2d3fa7f12 100644
Binary files a/assets/images/landing-2.png and b/assets/images/landing-2.png differ
diff --git a/e2e-tests/footer/notifications/notifications.spec.js b/e2e-tests/footer/notifications/notifications.spec.js
new file mode 100644
index 000000000..238afcc98
--- /dev/null
+++ b/e2e-tests/footer/notifications/notifications.spec.js
@@ -0,0 +1,25 @@
+import { test, expect } from '../../../playwright';
+
+test.describe('Notifications Modal', () => {
+ test('should open notifications modal when clicking bell icon and close with close button', async ({ page }) => {
+ // Get the notification bell icon in the status bar
+ const notificationBell = page.getByLabel('Check all Notifications');
+
+ // Click on the bell icon to open notifications
+ await notificationBell.click();
+
+ // Get modal elements
+ const notificationsModal = page.locator('.bruno-modal');
+ const modalCloseButton = notificationsModal.locator('div.bruno-modal-header div.close');
+
+ // Verify modal is visible and has the correct title
+ await expect(notificationsModal).toBeVisible();
+ await expect(notificationsModal.locator('.bruno-modal-header-title')).toContainText('NOTIFICATIONS');
+
+ // Click the close button
+ await modalCloseButton.click();
+
+ // Verify modal is closed
+ await expect(notificationsModal).not.toBeVisible();
+ });
+});
\ No newline at end of file
diff --git a/e2e-tests/footer/sidebar-toggle/sidebar-toggle.spec.js b/e2e-tests/footer/sidebar-toggle/sidebar-toggle.spec.js
new file mode 100644
index 000000000..d8e7dfb21
--- /dev/null
+++ b/e2e-tests/footer/sidebar-toggle/sidebar-toggle.spec.js
@@ -0,0 +1,36 @@
+import { test, expect } from '../../../playwright';
+
+test.describe('Sidebar Toggle', () => {
+ test('should toggle sidebar visibility when clicking the toggle button', async ({ page }) => {
+ // Get the sidebar and toggle button elements
+ const sidebar = page.locator('aside.sidebar');
+ const toggleButton = page.getByLabel('Toggle Sidebar');
+ const dragHandle = page.locator('.sidebar-drag-handle');
+
+ // Initial state - sidebar and drag handle should be visible
+ await expect(sidebar).toBeVisible();
+ await expect(dragHandle).toBeVisible();
+
+ // Click toggle to hide sidebar
+ await toggleButton.click();
+
+ // Wait for transition to complete and verify sidebar and drag handle are hidden
+ await expect(sidebar).not.toBeVisible();
+ await expect(dragHandle).not.toBeVisible();
+
+ // Verify the sidebar has collapsed width
+ const sidebarBox = await sidebar.boundingBox();
+ expect(sidebarBox?.width).toBe(0);
+
+ // Click toggle again to show sidebar
+ await toggleButton.click();
+
+ // Wait for transition and verify sidebar and drag handle are visible again
+ await expect(sidebar).toBeVisible();
+ await expect(dragHandle).toBeVisible();
+
+ // Verify the sidebar has expanded width
+ const expandedSidebarBox = await sidebar.boundingBox();
+ expect(expandedSidebarBox?.width).toBeGreaterThan(0);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js b/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js
new file mode 100644
index 000000000..bad3221fb
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
+ return (
+
+
+
+
+ {!collapsed && (
+
+ )}
+
+ );
+};
+
+export default IconSidebarToggle;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js
index 0667a4146..951cb5f6e 100644
--- a/packages/bruno-app/src/components/Notifications/index.js
+++ b/packages/bruno-app/src/components/Notifications/index.js
@@ -79,7 +79,7 @@ const Notifications = () => {
const modalCustomHeader = (
-
NOTIFICATIONS
+
NOTIFICATIONS
{unreadNotifications.length > 0 && (
<>
diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js
index ec0dd5c3a..0f8b8b807 100644
--- a/packages/bruno-app/src/components/Preferences/Beta/index.js
+++ b/packages/bruno-app/src/components/Preferences/Beta/index.js
@@ -84,7 +84,7 @@ const Beta = ({ close }) => {
Beta Features
- Enable beta features, these features may be unstable or incomplete.
+ Beta features are experimental previews that may change before full release. Try them and share feedback.
@@ -103,6 +103,16 @@ const Beta = ({ close }) => {
{feature.label}
+ {feature.id === 'grpc' && (
+
+ Share feedback
+
+ )}
{feature.description}
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js
index 3b95b708f..9819068b3 100644
--- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js
@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
name: 'OAuth2',
mode: 'oauth2'
},
+ {
+ name: 'WSSE Auth',
+ mode: 'wsse'
+ },
{
name: 'Inherit',
mode: 'inherit'
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js
index b30be89e5..9f9ea9070 100644
--- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js
@@ -6,6 +6,7 @@ import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import OAuth2 from '../../Auth/OAuth2/index';
+import WsseAuth from '../../Auth/WsseAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
@@ -13,7 +14,10 @@ import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/c
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
// List of auth modes supported by gRPC
-const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
+// Note: Only header-based auth modes work with gRPC
+// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
+// and cannot be supported in gRPC requests as of now
+const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
const GrpcAuth = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -83,6 +87,9 @@ const GrpcAuth = ({ item, collection }) => {
case 'oauth2': {
return
;
}
+ case 'wsse': {
+ return
;
+ }
case 'inherit': {
const source = getEffectiveAuthSource();
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
index 0b1b9df9c..b5c2c69a7 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
@@ -147,7 +147,7 @@ const QueryParams = ({ item, collection }) => {
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js
index 10177c0f8..c649f740f 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js
@@ -9,7 +9,6 @@ const Wrapper = styled.div`
.method-selector {
border-radius: 3px;
- min-width: 90px;
.tippy-box {
max-width: 150px !important;
@@ -21,6 +20,28 @@ const Wrapper = styled.div`
}
}
+ input {
+ background-color: ${(props) => props.theme.requestTabPanel.url.bg};
+ outline: none;
+ box-shadow: none;
+ text-align: left;
+
+ &:focus {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+ }
+
+ .method-span {
+ width: 70px;
+ min-width: 70px;
+ max-width: 90px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ display: inline-block;
+ }
+
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
index c2bdf15f1..67e9a42dd 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
@@ -1,52 +1,142 @@
-import React, { useRef, forwardRef } from 'react';
+import React, { useState, useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
-const HttpMethodSelector = ({ method, onMethodSelect }) => {
- const dropdownTippyRef = useRef();
- const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+const STANDARD_METHODS = Object.freeze(['GET','POST','PUT','DELETE','PATCH','OPTIONS','HEAD','TRACE','CONNECT']);
- const Icon = forwardRef((props, ref) => {
+const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
+
+const DEFAULT_METHOD = 'GET';
+
+function Verb({ verb, onSelect }) {
+ return (
+ onSelect(verb)}>
+ {verb}
+
+ );
+}
+
+const Icon = forwardRef(function IconComponent(
+ { isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
+ ref
+) {
+ if (isCustomMode) {
return (
-
-
- {method}
-
-
-
-
+
+
);
- });
+ }
- const handleMethodSelect = (verb) => onMethodSelect(verb);
-
- const Verb = ({ verb }) => {
- return (
-
{
- dropdownTippyRef.current.hide();
- handleMethodSelect(verb);
- }}
+ return (
+
+
- {verb}
-
- );
+
+ {inputValue}
+
+
+
+
+ );
+});
+
+const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
+ const [isCustomMode, setIsCustomMode] = useState(false);
+ const dropdownTippyRef = useRef();
+ const inputRef = useRef();
+
+ const blurInput = () => inputRef.current?.blur();
+
+ const handleInputChange = (e) => {
+ const val = e.target.value.toUpperCase();
+ onMethodSelect(val);
};
+ const handleDropdownSelect = (verb) => {
+ onMethodSelect(verb);
+ setIsCustomMode(false);
+ dropdownTippyRef.current?.hide();
+ blurInput();
+ };
+
+ const handleBlur = () => {
+ setIsCustomMode(false);
+ };
+
+ const handleAddCustomMethod = () => {
+ setIsCustomMode(true);
+ onMethodSelect('');
+ dropdownTippyRef.current?.hide();
+
+ setTimeout(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, 0);
+ };
+
+ const handleKeyDown = (e) => {
+ switch (e.key) {
+ case KEY.ESCAPE:
+ setIsCustomMode(false);
+ blurInput();
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ case KEY.ENTER:
+ onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
+ setIsCustomMode(false);
+ blurInput();
+ return;
+ default:
+ return;
+ }
+ };
+
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
return (
-
-
} placement="bottom-start">
-
-
-
-
-
-
-
+
+
+ }
+ placement="bottom-start"
+ >
+
+ {STANDARD_METHODS.map((verb) => (
+
+ ))}
+
+ + Add Custom
+
+
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
index 3fe47b041..2eb6dc375 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
-
+
{isGrpc ? (
gRPC
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index d50d528b3..fcba790a6 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -18,6 +18,7 @@ const RequestTabs = () => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
+ const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const getTabClassname = (tab, index) => {
@@ -49,7 +50,8 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
- const maxTablistWidth = screenWidth - leftSidebarWidth - 150;
+ const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
+ const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
const showChevrons = maxTablistWidth < tabsWidth;
diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx
index 3071f2989..7956eb1ad 100644
--- a/packages/bruno-app/src/components/RunnerResults/index.jsx
+++ b/packages/bruno-app/src/components/RunnerResults/index.jsx
@@ -116,7 +116,7 @@ export default function RunnerResults({ collection }) {
displayName: getDisplayName(collection.pathname, info.pathname, info.name),
tags: [...(info.request?.tags || [])].sort(),
};
- if (newItem.status !== 'error' && newItem.status !== 'skipped') {
+ if (newItem.status !== 'error' && newItem.status !== 'skipped' && newItem.status !== 'running') {
newItem.testStatus = getTestStatus(newItem.testResults);
newItem.assertionStatus = getTestStatus(newItem.assertionResults);
newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 7b331c42c..3eb6707e0 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -13,6 +13,7 @@ import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
+import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -45,6 +46,7 @@ const CreateCollection = ({ onClose }) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
.then(() => {
toast.success('Collection created!');
+ dispatch(toggleSidebarCollapse());
onClose();
})
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
index 20f1f5ee4..ef4d60db4 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
@@ -501,7 +501,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
{!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
-
+
formik.setFieldValue('requestMethod', val)}
diff --git a/packages/bruno-app/src/components/Sidebar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/StyledWrapper.js
index e64c7a3f4..2cec70df5 100644
--- a/packages/bruno-app/src/components/Sidebar/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/StyledWrapper.js
@@ -5,6 +5,7 @@ const Wrapper = styled.div`
aside {
background-color: ${(props) => props.theme.sidebar.bg};
+ overflow: hidden;
.collection-title {
line-height: 1.5;
@@ -41,7 +42,7 @@ const Wrapper = styled.div`
}
}
- div.drag-sidebar {
+ div.sidebar-drag-handle {
display: flex;
align-items: center;
justify-content: center;
@@ -50,6 +51,7 @@ const Wrapper = styled.div`
background-color: transparent;
width: 6px;
right: -3px;
+ transition: opacity 0.2s ease;
&:hover div.drag-request-border {
width: 2px;
diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js
index 1ba71b1ab..bd8b319c3 100644
--- a/packages/bruno-app/src/components/Sidebar/index.js
+++ b/packages/bruno-app/src/components/Sidebar/index.js
@@ -1,36 +1,37 @@
import TitleBar from './TitleBar';
import Collections from './Collections';
import StyledWrapper from './StyledWrapper';
-import { useApp } from 'providers/App';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
-import { useTheme } from 'providers/Theme';
const MIN_LEFT_SIDEBAR_WIDTH = 221;
const MAX_LEFT_SIDEBAR_WIDTH = 600;
const Sidebar = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
- const { version } = useApp();
+ const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
-
- const { storedTheme } = useTheme();
+ const lastWidthRef = useRef(leftSidebarWidth);
const dispatch = useDispatch();
const [dragging, setDragging] = useState(false);
+ const currentWidth = sidebarCollapsed ? 0 : asideWidth;
+
+ // Clamp helper keeps width in allowed range
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
+
const handleMouseMove = (e) => {
- if (dragging) {
- e.preventDefault();
- let width = e.clientX + 2;
- if (width < MIN_LEFT_SIDEBAR_WIDTH || width > MAX_LEFT_SIDEBAR_WIDTH) {
- return;
- }
- setAsideWidth(width);
- }
+ if (!dragging || sidebarCollapsed) return;
+ e.preventDefault();
+ const nextWidth = clamp(e.clientX + 2, MIN_LEFT_SIDEBAR_WIDTH, MAX_LEFT_SIDEBAR_WIDTH);
+ if (Math.abs(nextWidth - lastWidthRef.current) < 3) return;
+ lastWidthRef.current = nextWidth;
+ setAsideWidth(nextWidth);
};
+
const handleMouseUp = (e) => {
if (dragging) {
e.preventDefault();
@@ -49,6 +50,9 @@ const Sidebar = () => {
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
+ if (sidebarCollapsed) {
+ return;
+ }
setDragging(true);
dispatch(
updateIsDragging({
@@ -73,7 +77,7 @@ const Sidebar = () => {
return (
-
+
@@ -84,9 +88,11 @@ const Sidebar = () => {
-
+ {!sidebarCollapsed && (
+
+ )}
);
};
diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js
index 64f7c8de0..a98db747b 100644
--- a/packages/bruno-app/src/components/StatusBar/index.js
+++ b/packages/bruno-app/src/components/StatusBar/index.js
@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconSettings, IconCookie, IconTool } from '@tabler/icons';
+import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import ToolHint from 'components/ToolHint';
import Preferences from 'components/Preferences';
import Cookies from 'components/Cookies';
import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
-import { showPreferences } from 'providers/ReduxStore/slices/app';
+import { showPreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -15,6 +16,7 @@ const StatusBar = () => {
const dispatch = useDispatch();
const preferencesOpen = useSelector((state) => state.app.showPreferences);
const logs = useSelector((state) => state.logs.logs);
+ const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
@@ -59,6 +61,16 @@ const StatusBar = () => {
+
+ dispatch(toggleSidebarCollapse())}
+ >
+
+
+
+
{
const dispatch = useDispatch();
const { t } = useTranslation();
+ const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
+ const collections = useSelector((state) => state.collections.collections);
const [importedCollection, setImportedCollection] = useState(null);
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(t('WELCOME.COLLECTION_OPEN_ERROR')));
+ dispatch(openCollection())
+ .catch((err) => {
+ console.error(err);
+ toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'));
+ });
};
const handleImportCollection = ({ collection }) => {
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 7f8bdba37..d8a239907 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -14,6 +14,7 @@ import {
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
+import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
export const HotkeysContext = React.createContext();
@@ -224,6 +225,18 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, dispatch]);
+ // Collapse sidebar (ctrl/cmd + \)
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
+ dispatch(toggleSidebarCollapse());
+ return false;
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
+ };
+ }, [dispatch]);
+
const currentCollection = getCurrentCollection();
return (
diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
index b88300c8f..0fd9bf1bc 100644
--- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
+++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
@@ -20,7 +20,8 @@ const KeyMapping = {
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
- closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }
+ closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
+ collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
};
/**
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index 33c2a6162..fe12defcc 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -5,6 +5,7 @@ const initialState = {
isDragging: false,
idbConnectionReady: false,
leftSidebarWidth: 222,
+ sidebarCollapsed: false,
screenWidth: 500,
showHomePage: false,
showPreferences: false,
@@ -90,6 +91,9 @@ export const appSlice = createSlice({
...state.generateCode,
...action.payload
};
+ },
+ toggleSidebarCollapse: (state) => {
+ state.sidebarCollapsed = !state.sidebarCollapsed;
}
}
});
@@ -109,7 +113,8 @@ export const {
removeTaskFromQueue,
removeAllTasksFromQueue,
updateSystemProxyEnvVariables,
- updateGenerateCode
+ updateGenerateCode,
+ toggleSidebarCollapse
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {
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 8340a2795..28d5f4038 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -7,7 +7,7 @@ import get from 'lodash/get';
import set from 'lodash/set';
import trim from 'lodash/trim';
import path from 'utils/common/path';
-import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
+import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import {
findCollectionByUid,
@@ -41,7 +41,8 @@ import {
initRunRequestEvent,
updateRunnerConfiguration as _updateRunnerConfiguration,
updateActiveConnections,
- saveRequest as _saveRequest
+ saveRequest as _saveRequest,
+ saveEnvironment as _saveEnvironment
} from './index';
import { each } from 'lodash';
@@ -59,6 +60,7 @@ import {
calculateDraggedItemNewPathname
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
+import { buildPersistedEnvVariables } from 'utils/environments';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab } from './index';
@@ -1241,8 +1243,16 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
+
+ // strip "ephemeral" metadata
+ const variablesToCopy = (baseEnv.variables || [])
+ .filter((v) => !v.ephemeral)
+ .map(({ ephemeral, ...rest }) => {
+ return rest;
+ });
+
ipcRenderer
- .invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
+ .invoke('renderer:create-environment', collection.pathname, sanitizedName, variablesToCopy)
.then(
dispatch(
updateLastAction({
@@ -1323,12 +1333,27 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
- environment.variables = variables;
+ /*
+ Modal Save writes what the user sees:
+ - Non-ephemeral vars are saved as-is (without metadata)
+ - Ephemeral vars:
+ - if persistedValue exists, save that (explicit persisted case)
+ - otherwise save the current UI value (treat as user-authored)
+ */
+ const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
+ environment.variables = persisted;
const { ipcRenderer } = window;
+ const envForValidation = cloneDeep(environment);
+
environmentSchema
.validate(environment)
- .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
+ .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envForValidation))
+ .then(() => {
+ // Immediately sync Redux to the saved (persisted) set so old ephemerals
+ // aren’t around when the watcher event arrives.
+ dispatch(_saveEnvironment({ variables: persisted, environmentUid, collectionUid }));
+ })
.then(resolve)
.catch(reject);
});
@@ -1385,12 +1410,15 @@ export const mergeAndPersistEnvironment =
}
});
- environment.variables = merged;
+ // Save only non-ephemeral vars, or ephemerals explicitly persisted this run
+ const persistedNames = new Set(Object.keys(persistentEnvVariables));
+ const environmentToSave = cloneDeep(environment);
+ environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames });
const { ipcRenderer } = window;
environmentSchema
- .validate(environment)
- .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
+ .validate(environmentToSave)
+ .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave))
.then(resolve)
.catch(reject);
});
@@ -1501,7 +1529,14 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
collectionSchema
.validate(collection)
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
- .then(resolve)
+ .then(() => {
+ // Expand sidebar if it's collapsed after collection is successfully opened
+ const state = getState();
+ if (state.app.sidebarCollapsed) {
+ dispatch(toggleSidebarCollapse());
+ }
+ 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 62dc848cc..698820710 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -320,7 +320,20 @@ export const collectionsSlice = createSlice({
const variable = find(activeEnvironment.variables, (v) => v.name === key);
if (variable) {
- variable.value = value;
+ // For updates coming from scripts, treat them as ephemeral overlays.
+ if (variable.value !== value) {
+ /*
+ Overlay (persist: false): keep new value in Redux for UI and mark ephemeral
+ so it isn't written to disk. persistedValue stores the previous on-disk value;
+ save/persist uses that base unless the key is explicitly persisted.
+ */
+ const previousValue = variable.value;
+ variable.value = value;
+ variable.ephemeral = true;
+ if (variable.persistedValue === undefined) {
+ variable.persistedValue = previousValue;
+ }
+ }
} else {
// __name__ is a private variable used to store the name of the environment
// this is not a user defined variable and hence should not be updated
@@ -331,7 +344,8 @@ export const collectionsSlice = createSlice({
secret: false,
enabled: true,
type: 'text',
- uid: uuid()
+ uid: uuid(),
+ ephemeral: true,
});
}
}
@@ -2320,7 +2334,21 @@ export const collectionsSlice = createSlice({
const existingEnv = collection.environments.find((e) => e.uid === environment.uid);
if (existingEnv) {
+ const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
existingEnv.variables = environment.variables;
+ /*
+ Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
+ */
+ prevEphemerals.forEach((ev) => {
+ const target = existingEnv.variables?.find((v) => v.name === ev.name);
+ if (target) {
+ if (target.value !== ev.value) {
+ if (target.persistedValue === undefined) target.persistedValue = target.value;
+ target.value = ev.value;
+ }
+ target.ephemeral = true;
+ }
+ });
} else {
collection.environments.push(environment);
collection.environments.sort((a, b) => a.name.localeCompare(b.name));
@@ -2457,6 +2485,9 @@ export const collectionsSlice = createSlice({
if (type === 'testrun-ended') {
const info = collection.runnerResult.info;
info.status = 'ended';
+ if (action.payload.runCompletionTime) {
+ info.runCompletionTime = action.payload.runCompletionTime;
+ }
if (action.payload.statusText) {
info.statusText = action.payload.statusText;
}
diff --git a/packages/bruno-app/src/styles/globals.css b/packages/bruno-app/src/styles/globals.css
index ef2de6bbe..de18baa3d 100644
--- a/packages/bruno-app/src/styles/globals.css
+++ b/packages/bruno-app/src/styles/globals.css
@@ -131,6 +131,10 @@
--px-12: 2px !important;
}
+.CodeMirror-hints {
+ z-index: 20 !important;
+}
+
.graphiql-container {
background: transparent !important;
}
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index e950e1ebf..54c813198 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1,5 +1,6 @@
import { cloneDeep, each, filter, find, findIndex, get, isEqual, isString, map, sortBy } from 'lodash';
import { uuid } from 'utils/common';
+import { buildPersistedEnvVariables } from 'utils/environments';
import { sortByNameThenSequence } from 'utils/common/index';
import path from 'utils/common/path';
import { isRequestTagsIncluded } from '@usebruno/common';
@@ -334,7 +335,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
- autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
+ additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
};
break;
case 'authorization_code':
@@ -354,7 +356,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
- autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
+ additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
};
break;
case 'implicit':
@@ -369,7 +372,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
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)
+ autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
+ additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
};
break;
case 'client_credentials':
@@ -386,7 +390,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
tokenHeaderPrefix: get(si.request, 'auth.oauth2.tokenHeaderPrefix', ''),
tokenQueryKey: get(si.request, 'auth.oauth2.tokenQueryKey', ''),
autoFetchToken: get(si.request, 'auth.oauth2.autoFetchToken', true),
- autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true)
+ autoRefreshToken: get(si.request, 'auth.oauth2.autoRefreshToken', true),
+ additionalParameters: get(si.request, 'auth.oauth2.additionalParameters', {})
};
break;
}
@@ -501,7 +506,11 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
collectionToSave.version = '1';
collectionToSave.items = [];
collectionToSave.activeEnvironmentUid = collection.activeEnvironmentUid;
- collectionToSave.environments = collection.environments || [];
+ // Save environments without runtime metadata (ephemeral/persistedValue)
+ collectionToSave.environments = (collection.environments || []).map((env) => ({
+ ...env,
+ variables: buildPersistedEnvVariables(env?.variables, { mode: 'save' })
+ }));
collectionToSave.root = {
request: {}
diff --git a/packages/bruno-app/src/utils/environments.js b/packages/bruno-app/src/utils/environments.js
new file mode 100644
index 000000000..50fcfcdf7
--- /dev/null
+++ b/packages/bruno-app/src/utils/environments.js
@@ -0,0 +1,31 @@
+const isPersistableEnvVarForMerge = (persistedNames) => (v) => {
+ return !v?.ephemeral || v?.persistedValue !== undefined || (v?.name && persistedNames.has(v.name));
+};
+
+const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
+ const { ephemeral, persistedValue, ...rest } = v || {};
+ if (v?.ephemeral && persistedValue !== undefined && !(v?.name && persistedNames.has(v.name))) {
+ return { ...rest, value: persistedValue };
+ }
+ return rest;
+};
+
+const toPersistedEnvVarForSave = (v) => {
+ const { ephemeral, persistedValue, ...rest } = v || {};
+ return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
+};
+
+/*
+ High-level builder for persisted variables
+ - mode 'save': write what the user sees
+ - mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
+*/
+export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
+ const src = Array.isArray(variables) ? variables : [];
+ if (mode === 'merge') {
+ const names = persistedNames instanceof Set ? persistedNames : new Set();
+ return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
+ }
+ // default to save mode
+ return src.map(toPersistedEnvVarForSave);
+};
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index e3b7aa3ea..261244949 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -623,6 +623,7 @@ const handler = async function (argv) {
}
const summary = printRunSummary(results);
+ const runCompletionTime = new Date().toISOString();
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
@@ -636,7 +637,7 @@ const handler = async function (argv) {
const reporters = {
'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
'junit': (path) => makeJUnitOutput(results, path),
- 'html': (path) => makeHtmlOutput(outputJson, path),
+ 'html': (path) => makeHtmlOutput(outputJson, path, runCompletionTime),
}
for (const formatter of Object.keys(formats))
diff --git a/packages/bruno-cli/src/reporters/html-template.html b/packages/bruno-cli/src/reporters/html-template.html
deleted file mode 100644
index b4cbca0ac..000000000
--- a/packages/bruno-cli/src/reporters/html-template.html
+++ /dev/null
@@ -1,637 +0,0 @@
-
-
-
-
-
-
-
-
- Bruno
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Dark
- Light
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/bruno-cli/src/reporters/html.js b/packages/bruno-cli/src/reporters/html.js
index 7fb594224..0c7975cd3 100644
--- a/packages/bruno-cli/src/reporters/html.js
+++ b/packages/bruno-cli/src/reporters/html.js
@@ -1,13 +1,31 @@
const fs = require('fs');
-const path = require('path');
+const { generateHtmlReport } = require('@usebruno/common/runner');
+const { CLI_VERSION } = require('../constants');
-const makeHtmlOutput = async (results, outputPath) => {
- const resultsJson = JSON.stringify(results, null, 2);
-
- const reportPath = path.join(__dirname, 'html-template.html');
- const template = fs.readFileSync(reportPath, 'utf8');
-
- fs.writeFileSync(outputPath, template.replace('__RESULTS_JSON__', resultsJson));
+const makeHtmlOutput = async (results, outputPath, runCompletionTime) => {
+ let runnerResults = results;
+ if (!results) {
+ runnerResults = [];
+ } else if (results.results) {
+ // Convert CLI format to expected format: array of { iterationIndex, results, summary }
+ runnerResults = [{
+ iterationIndex: 0,
+ results: results.results,
+ summary: results.summary
+ }];
+ } else if (Array.isArray(results)) {
+ runnerResults = results;
+ }
+
+ const environment = runnerResults.length > 0 ? runnerResults[0].environment : null;
+
+ const htmlString = generateHtmlReport({
+ runnerResults: runnerResults,
+ version: `usebruno v${CLI_VERSION}`,
+ environment: environment,
+ runCompletionTime: runCompletionTime
+ });
+ fs.writeFileSync(outputPath, htmlString);
};
module.exports = makeHtmlOutput;
diff --git a/packages/bruno-cli/src/utils/axios-instance.js b/packages/bruno-cli/src/utils/axios-instance.js
index e919412e7..1d0e7ccd9 100644
--- a/packages/bruno-cli/src/utils/axios-instance.js
+++ b/packages/bruno-cli/src/utils/axios-instance.js
@@ -111,7 +111,7 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
}
if (!disableCookies){
- saveCookies(redirectUrl, error.response.headers);
+ saveCookies(error.config.url, error.response.headers);
}
const requestConfig = createRedirectConfig(error, redirectUrl);
diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js
index 31b7b5b12..9c8766ca3 100644
--- a/packages/bruno-cli/src/utils/bru.js
+++ b/packages/bruno-cli/src/utils/bru.js
@@ -77,6 +77,8 @@ const bruToJson = (bru) => {
request: {
url: _.get(json, requestType === 'grpc-request' ? 'grpc.url' : 'http.url'),
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
+ // Preserving special characters in custom methods. Using _.upperCase strips special characters.
+ method: String(_.get(json, 'http.method') ?? '').toUpperCase(),
auth: _.get(json, 'auth', {}),
params: _.get(json, 'params', []),
vars: _.get(json, 'vars', []),
diff --git a/packages/bruno-cli/tests/runner/report-metadata.spec.js b/packages/bruno-cli/tests/runner/report-metadata.spec.js
new file mode 100644
index 000000000..9732e4aeb
--- /dev/null
+++ b/packages/bruno-cli/tests/runner/report-metadata.spec.js
@@ -0,0 +1,52 @@
+const { describe, it, expect } = require('@jest/globals');
+const { generateHtmlReport } = require('@usebruno/common/runner');
+
+describe('HTML Report Generation', () => {
+ it('should include all metadata in the HTML report', async () => {
+ // Sample test results
+ const mockResults = [
+ {
+ iterationIndex: 0,
+ environment: 'production',
+ results: [],
+ summary: {
+ totalRequests: 1,
+ passedRequests: 1,
+ failedRequests: 0,
+ errorRequests: 0,
+ skippedRequests: 0,
+ totalAssertions: 0,
+ passedAssertions: 0,
+ failedAssertions: 0,
+ totalTests: 0,
+ passedTests: 0,
+ failedTests: 0
+ }
+ }
+ ];
+
+ // Generate HTML using mock data
+ const htmlString = generateHtmlReport({
+ runnerResults: mockResults,
+ version: 'usebruno v1.16.0',
+ environment: 'production',
+ runCompletionTime: '2024-01-15T14:30:45.123Z'
+ });
+
+ // Verify the HTML contains expected metadata structure
+ expect(htmlString).toContain('Bruno run dashboard');
+ expect(htmlString).toContain('Date & Time');
+ expect(htmlString).toContain('Version');
+ expect(htmlString).toContain('Environment');
+ expect(htmlString).toContain('Total run duration');
+ expect(htmlString).toContain('Total data received');
+ expect(htmlString).toContain('Average response time');
+
+ expect(htmlString).toContain('{{ runCompletionTime }}');
+ expect(htmlString).toContain('{{ brunoVersion }}');
+ expect(htmlString).toContain('{{ environment }}');
+ expect(htmlString).toContain('{{ totalDuration }}');
+ expect(htmlString).toContain('{{ totalDataReceived }}');
+ expect(htmlString).toContain('{{ averageResponseTime }}');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts
index e72c1d847..40d298e78 100644
--- a/packages/bruno-common/src/index.ts
+++ b/packages/bruno-common/src/index.ts
@@ -2,4 +2,4 @@ export { mockDataFunctions } from './utils/faker-functions';
export { default as interpolate } from './interpolate';
export { default as isRequestTagsIncluded } from './tags';
-export * as utils from './utils';
\ No newline at end of file
+export * as utils from './utils';
diff --git a/packages/bruno-common/src/interpolate/index.spec.ts b/packages/bruno-common/src/interpolate/index.spec.ts
index 025898a42..b47be2fdd 100644
--- a/packages/bruno-common/src/interpolate/index.spec.ts
+++ b/packages/bruno-common/src/interpolate/index.spec.ts
@@ -1,18 +1,38 @@
import interpolate from './index';
import moment from 'moment';
+
+const BRUNO_BIRTH_DATE = new Date('2019-08-08');
+
+const calculateAgeFromBirthDate = (birthDate = BRUNO_BIRTH_DATE) => {
+ const today = new Date();
+ let age = today.getFullYear() - birthDate.getFullYear();
+
+ const hasBirthdayPassedThisYear =
+ today.getMonth() > birthDate.getMonth() ||
+ (today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
+
+ if (!hasBirthdayPassedThisYear) {
+ age--;
+ }
+
+ return age;
+};
+
+const BRUNO_AGE = calculateAgeFromBirthDate(BRUNO_BIRTH_DATE);
+
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';
const inputObject = {
'user.name': 'Bruno',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
+ expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
});
it('should handle missing values by leaving the placeholders unchanged using {{}} as delimiters', () => {
@@ -32,7 +52,7 @@ describe('interpolate', () => {
const inputObject = {
user: {
full_name: 'Bruno',
- age: 4,
+ age: BRUNO_AGE,
'fav-food': ['egg', 'meat'],
'want.attention': true
}
@@ -45,7 +65,7 @@ describe('interpolate', () => {
`;
const expectedStr = `
Hi, I am Bruno,
- I am 4 years old.
+ I am ${BRUNO_AGE} years old.
My favorite food is egg and meat.
I like attention: true
`;
@@ -58,13 +78,13 @@ describe('interpolate', () => {
const inputObject = {
'user.name': 'Bruno',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is {{ user.name }} and I am 4 years old');
+ expect(result).toBe(`Hello, my name is {{ user.name }} and I am ${BRUNO_AGE} years old`);
});
test('should give precedence to the last key in case of duplicates (not at the top level)', () => {
@@ -74,14 +94,14 @@ describe('interpolate', () => {
'user.name': 'Bruno',
user: {
name: 'Not _Bruno_',
- age: 4
+ age: BRUNO_AGE
}
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Bruno and Not _Bruno_ I am 4 years old');
+ expect(result).toBe(`Hello, my name is Bruno and Not _Bruno_ I am ${BRUNO_AGE} years old`);
});
});
@@ -179,13 +199,13 @@ describe('interpolate - recursive', () => {
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
'user.name': 'Bruno',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
+ expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
});
it('should replace placeholders with 2 level of recursion with values from the object', () => {
@@ -195,13 +215,13 @@ describe('interpolate - recursive', () => {
'user.name': 'Bruno {{user.lastName}}',
'user.lastName': 'Dog',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
+ expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
});
it('should replace placeholders with 3 level of recursion with values from the object', () => {
@@ -212,13 +232,13 @@ describe('interpolate - recursive', () => {
'user.name': 'Bruno {{user.lastName}}',
'user.lastName': 'Dog',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
+ expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
});
it('should handle missing values with 1 level of recursion by leaving the placeholders unchanged using {{}} as delimiters', () => {
@@ -226,13 +246,13 @@ describe('interpolate - recursive', () => {
const inputObject = {
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
user: {
- age: 4
+ age: BRUNO_AGE
}
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('Hello, my name is {{user.name}} and I am 4 years old');
+ expect(result).toBe(`Hello, my name is {{user.name}} and I am ${BRUNO_AGE} years old`);
});
it('should handle all valid keys with 1 level of recursion', () => {
@@ -246,7 +266,7 @@ describe('interpolate - recursive', () => {
user: {
message,
full_name: 'Bruno',
- age: 4,
+ age: BRUNO_AGE,
'fav-food': ['egg', 'meat'],
'want.attention': true
}
@@ -255,7 +275,7 @@ describe('interpolate - recursive', () => {
const inputStr = '{{user.message}}';
const expectedStr = `
Hi, I am Bruno,
- I am 4 years old.
+ I am ${BRUNO_AGE} years old.
My favorite food is egg and meat.
I like attention: true
`;
@@ -361,32 +381,32 @@ describe('interpolate - object handling', () => {
it('should stringify simple objects', () => {
const inputString = 'User: {{user}}';
const inputObject = {
- 'user': { name: 'Bruno', age: 4 }
+ 'user': { name: 'Bruno', age: BRUNO_AGE }
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('User: {"name":"Bruno","age":4}');
+ expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
});
it('should stringify simple objects (dot notation)', () => {
const inputString = 'User: {{user.data}}';
const inputObject = {
- 'user.data': { name: 'Bruno', age: 4 }
+ 'user.data': { name: 'Bruno', age: BRUNO_AGE }
};
const result = interpolate(inputString, inputObject);
- expect(result).toBe('User: {"name":"Bruno","age":4}');
+ expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
});
it('should stringify nested objects', () => {
const inputString = 'User: {{user}}';
const inputObject = {
- 'user': {
- name: 'Bruno',
- age: 4,
- preferences: {
+ 'user': {
+ name: 'Bruno',
+ age: BRUNO_AGE,
+ preferences: {
food: ['egg', 'meat'],
toys: { favorite: 'ball' }
}
@@ -395,7 +415,7 @@ describe('interpolate - object handling', () => {
const result = interpolate(inputString, inputObject);
- expect(result).toBe('User: {"name":"Bruno","age":4,"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}');
+ expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE},"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}`);
});
it('should stringify arrays', () => {
diff --git a/packages/bruno-common/src/interpolate/index.ts b/packages/bruno-common/src/interpolate/index.ts
index 83d803480..daa10ab98 100644
--- a/packages/bruno-common/src/interpolate/index.ts
+++ b/packages/bruno-common/src/interpolate/index.ts
@@ -5,10 +5,10 @@
* Ex: interpolate('Hello, my name is ${user.name} and I am ${user.age} years old', {
* "user.name": "Bruno",
* "user": {
- * "age": 4
+ * "age": 6
* }
* });
- * Output: Hello, my name is Bruno and I am 4 years old
+ * Output: Hello, my name is Bruno and I am 6 years old
*/
import { mockDataFunctions } from '../utils/faker-functions';
diff --git a/packages/bruno-common/src/runner/reports/html/generate-report.ts b/packages/bruno-common/src/runner/reports/html/generate-report.ts
index 7309c483e..e08ddce29 100644
--- a/packages/bruno-common/src/runner/reports/html/generate-report.ts
+++ b/packages/bruno-common/src/runner/reports/html/generate-report.ts
@@ -3,9 +3,15 @@ import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from
import htmlTemplateString from "./template";
const generateHtmlReport = ({
- runnerResults
+ runnerResults,
+ version = '', // Default to empty string if not provided
+ environment = null, // Default environment if not provided
+ runCompletionTime = '' // Default run completion time if not provided
}: {
- runnerResults: T_RunnerResults[]
+ runnerResults: T_RunnerResults[];
+ version?: string;
+ environment?: string | null;
+ runCompletionTime?: string;
}): string => {
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
return {
@@ -31,7 +37,12 @@ const generateHtmlReport = ({
summary
}
});
- const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));
+ const htmlString = htmlTemplateString(encodeBase64(JSON.stringify({
+ results: resultsWithSummaryAndCleanData,
+ version,
+ environment,
+ runCompletionTime
+ })));
return htmlString;
};
diff --git a/packages/bruno-common/src/runner/reports/html/template.ts b/packages/bruno-common/src/runner/reports/html/template.ts
index cd0839e67..44589b81d 100644
--- a/packages/bruno-common/src/runner/reports/html/template.ts
+++ b/packages/bruno-common/src/runner/reports/html/template.ts
@@ -28,6 +28,37 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
.min-width-150 {
min-width: 150px;
}
+
+ /* Metadata card styling - minimal custom styles */
+ .metadata-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 8px;
+ margin-top: 12px;
+ }
+
+ .metadata-item {
+ text-align: center;
+ padding: 6px 8px;
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .metadata-label {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+ opacity: 0.7;
+ }
+
+ .metadata-value {
+ font-size: 0.8rem;
+ font-weight: normal;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
@@ -162,6 +193,35 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
+
+
+
+
@@ -213,12 +273,6 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
-
- s
-
@@ -400,10 +454,25 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
+ const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
const res = computed(() => {
- const rawResults = JSON.parse(decodeBase64('${resutsJsonString}'));
- return mergeTests(rawResults);
+ return mergeTests(rawResults.results);
+ });
+
+ const brunoVersion = computed(() => {
+ return rawResults.version || '-';
+ });
+
+ const environment = computed(() => {
+ return rawResults.environment || '-';
+ });
+
+ const runCompletionTime = computed(() => {
+ if (rawResults.runCompletionTime) {
+ return new Date(rawResults.runCompletionTime).toLocaleString();
+ }
+ return '-';
});
const currentTab = ref('summary');
@@ -422,6 +491,47 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
const theme = computed(() => {
return darkMode.value ? naive.darkTheme : null;
});
+
+ const totalDuration = computed(() => {
+ const total = res.value.reduce((totalTime, iteration) => {
+ return totalTime + iteration.results.reduce((sum, result) => sum + (result.runDuration || 0), 0);
+ }, 0);
+ return total > 0 ? Math.round(total * 1000) / 1000 + 's' : '-';
+ });
+
+ const totalDataReceived = computed(() => {
+ const bytes = res.value.reduce((total, iteration) => {
+ return total + iteration.results.reduce((sum, result) => {
+ const responseData = result.response?.data;
+ if (typeof responseData === 'string') {
+ return sum + new Blob([responseData]).size;
+ }
+ return sum + (JSON.stringify(responseData || {}).length || 0);
+ }, 0);
+ }, 0);
+
+ if (bytes === 0) return '-';
+ if (bytes < 1024) return bytes + 'B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB';
+ return (bytes / (1024 * 1024)).toFixed(2) + 'MB';
+ });
+
+ const averageResponseTime = computed(() => {
+ let totalTime = 0;
+ let count = 0;
+
+ res.value.forEach(iteration => {
+ iteration.results.forEach(result => {
+ if (result.response?.responseTime) {
+ totalTime += result.response.responseTime;
+ count++;
+ }
+ });
+ });
+
+ return count > 0 ? Math.round(totalTime / count) + 'ms' : '-';
+ });
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
darkMode.value = true;
}
@@ -434,7 +544,13 @@ export const htmlTemplateString = (resutsJsonString: string) =>`
theme,
darkMode,
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' }),
- currentTab
+ currentTab,
+ brunoVersion,
+ environment,
+ totalDuration,
+ totalDataReceived,
+ averageResponseTime,
+ runCompletionTime
};
}
};
diff --git a/packages/bruno-converters/readme.md b/packages/bruno-converters/readme.md
index af69f5268..8f8df3f44 100644
--- a/packages/bruno-converters/readme.md
+++ b/packages/bruno-converters/readme.md
@@ -31,7 +31,7 @@ const brunoEnvironment = postmanToBrunoEnvironment(postmanEnvironment);
### Convert Insomnia collection to Bruno collection
```javascript
-import { insomniaToBruno } from '@usebruno/converters';
+const { insomniaToBruno } = require('@usebruno/converters');
const brunoCollection = insomniaToBruno(insomniaCollection);
```
@@ -39,7 +39,7 @@ const brunoCollection = insomniaToBruno(insomniaCollection);
### Convert OpenAPI specification to Bruno collection
```javascript
-import { openApiToBruno } from '@usebruno/converters';
+const { openApiToBruno } = require('@usebruno/converters');
const brunoCollection = openApiToBruno(openApiSpecification);
```
@@ -75,4 +75,4 @@ const outputFilePath = path.resolve(__dirname, 'bruno-collection.json');
convertPostmanToBruno(inputFilePath, outputFilePath);
-```
\ No newline at end of file
+```
diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js
index 7d1cf3b11..79438c231 100644
--- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js
+++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js
@@ -60,7 +60,9 @@ const transformOpenapiRequestItem = (request) => {
mode: 'inherit',
basic: null,
bearer: null,
- digest: null
+ digest: null,
+ apikey: null,
+ oauth2: null
},
headers: [],
params: [],
@@ -108,13 +110,16 @@ const transformOpenapiRequestItem = (request) => {
}
});
- let auth;
- // allow operation override
+ // Handle explicit no-auth case where security: [] on the operation
+ if (Array.isArray(_operationObject.security) && _operationObject.security.length === 0) {
+ brunoRequestItem.request.auth.mode = 'inherit';
+ return brunoRequestItem;
+ }
+
+ let auth = null;
if (_operationObject.security && _operationObject.security.length > 0) {
- let schemeName = Object.keys(_operationObject.security[0])[0];
+ const 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) {
@@ -129,14 +134,87 @@ const transformOpenapiRequestItem = (request) => {
brunoRequestItem.request.auth.bearer = {
token: '{{token}}'
};
- } else if (auth.type === 'apiKey' && auth.in === 'header') {
- brunoRequestItem.request.headers.push({
- uid: uuid(),
- name: auth.name,
+ } else if (auth.type === 'http' && auth.scheme === 'digest') {
+ brunoRequestItem.request.auth.mode = 'digest';
+ brunoRequestItem.request.auth.digest = {
+ username: '{{username}}',
+ password: '{{password}}'
+ };
+ } else if (auth.type === 'apiKey') {
+ const apikeyConfig = {
+ key: auth.name,
value: '{{apiKey}}',
- description: 'Authentication header',
- enabled: true
- });
+ placement: auth.in === 'query' ? 'queryparams' : 'header'
+ };
+ brunoRequestItem.request.auth.mode = 'apikey';
+ brunoRequestItem.request.auth.apikey = apikeyConfig;
+
+ if (auth.in === 'header' || auth.in === 'cookie') {
+ brunoRequestItem.request.headers.push({
+ uid: uuid(),
+ name: auth.name,
+ value: '{{apiKey}}',
+ description: auth.description || '',
+ enabled: true
+ });
+ } else if (auth.in === 'query') {
+ brunoRequestItem.request.params.push({
+ uid: uuid(),
+ name: auth.name,
+ value: '{{apiKey}}',
+ description: auth.description || '',
+ enabled: true,
+ type: 'query'
+ });
+ }
+ } else if (auth.type === 'oauth2') {
+ // Determine flow (grant type)
+ let flows = auth.flows || {};
+ let grantType = 'client_credentials';
+ if (flows.authorizationCode) {
+ grantType = 'authorization_code';
+ } else if (flows.implicit) {
+ grantType = 'implicit';
+ } else if (flows.password) {
+ grantType = 'password';
+ } else if (flows.clientCredentials) {
+ grantType = 'client_credentials';
+ }
+
+ let flowConfig = {};
+ switch (grantType) {
+ case 'authorization_code':
+ flowConfig = flows.authorizationCode || {};
+ break;
+ case 'implicit':
+ flowConfig = flows.implicit || {};
+ break;
+ case 'password':
+ flowConfig = flows.password || {};
+ break;
+ case 'client_credentials':
+ default:
+ flowConfig = flows.clientCredentials || {};
+ break;
+ }
+
+ brunoRequestItem.request.auth.mode = 'oauth2';
+ brunoRequestItem.request.auth.oauth2 = {
+ grantType: grantType,
+ authorizationUrl: flowConfig.authorizationUrl || '{{oauth_authorize_url}}',
+ accessTokenUrl: flowConfig.tokenUrl || '{{oauth_token_url}}',
+ refreshTokenUrl: flowConfig.refreshUrl || '{{oauth_refresh_url}}',
+ callbackUrl: '{{oauth_callback_url}}',
+ clientId: '{{oauth_client_id}}',
+ clientSecret: '{{oauth_client_secret}}',
+ scope: Array.isArray(flowConfig.scopes) ? flowConfig.scopes.join(' ') : Object.keys(flowConfig.scopes || {}).join(' '),
+ state: '{{oauth_state}}',
+ credentialsPlacement: 'header',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ autoFetchToken: false,
+ autoRefreshToken: true
+ };
}
}
@@ -425,7 +503,9 @@ export const parseOpenApiCollection = (data) => {
mode: 'inherit',
basic: null,
bearer: null,
- digest: null
+ digest: null,
+ apikey: null,
+ oauth2: null
}
},
meta: {
@@ -439,6 +519,103 @@ export const parseOpenApiCollection = (data) => {
let ungroupedItems = ungroupedRequests.map(transformOpenapiRequestItem);
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
brunoCollection.items = brunoCollectionItems;
+
+ // Determine collection-level authentication based on global security requirements
+ const buildCollectionAuth = (scheme) => {
+ const authTemplate = {
+ mode: 'none',
+ basic: null,
+ bearer: null,
+ digest: null,
+ apikey: null,
+ oauth2: null,
+ };
+
+ if (!scheme) return authTemplate;
+
+ if (scheme.type === 'http' && scheme.scheme === 'basic') {
+ return {
+ ...authTemplate,
+ mode: 'basic',
+ basic: {
+ username: '{{username}}',
+ password: '{{password}}'
+ }
+ };
+ } else if (scheme.type === 'http' && scheme.scheme === 'bearer') {
+ return {
+ ...authTemplate,
+ mode: 'bearer',
+ bearer: {
+ token: '{{token}}'
+ }
+ };
+ } else if (scheme.type === 'http' && scheme.scheme === 'digest') {
+ return {
+ ...authTemplate,
+ mode: 'digest',
+ digest: {
+ username: '{{username}}',
+ password: '{{password}}'
+ }
+ };
+ } else if (scheme.type === 'apiKey') {
+ return {
+ ...authTemplate,
+ mode: 'apikey',
+ apikey: {
+ key: scheme.name,
+ value: '{{apiKey}}',
+ placement: scheme.in === 'query' ? 'queryparams' : 'header'
+ }
+ };
+ } else if (scheme.type === 'oauth2') {
+ let flows = scheme.flows || {};
+ let grantType = 'client_credentials';
+ if (flows.authorizationCode) {
+ grantType = 'authorization_code';
+ } else if (flows.implicit) {
+ grantType = 'implicit';
+ } else if (flows.password) {
+ grantType = 'password';
+ }
+ const flowConfig = grantType === 'authorization_code' ? flows.authorizationCode || {} : grantType === 'implicit' ? flows.implicit || {} : grantType === 'password' ? flows.password || {} : flows.clientCredentials || {};
+
+ return {
+ ...authTemplate,
+ mode: 'oauth2',
+ oauth2: {
+ grantType,
+ authorizationUrl: flowConfig.authorizationUrl || '{{oauth_authorize_url}}',
+ accessTokenUrl: flowConfig.tokenUrl || '{{oauth_token_url}}',
+ refreshTokenUrl: flowConfig.refreshUrl || '{{oauth_refresh_url}}',
+ callbackUrl: '{{oauth_callback_url}}',
+ clientId: '{{oauth_client_id}}',
+ clientSecret: '{{oauth_client_secret}}',
+ scope: Array.isArray(flowConfig.scopes) ? flowConfig.scopes.join(' ') : Object.keys(flowConfig.scopes || {}).join(' '),
+ state: '{{oauth_state}}',
+ credentialsPlacement: 'header',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ autoFetchToken: false,
+ autoRefreshToken: true
+ }
+ };
+ }
+ return authTemplate;
+ };
+
+ let collectionAuth = buildCollectionAuth(securityConfig.supported[0]);
+
+ brunoCollection.root = {
+ request: {
+ auth: collectionAuth,
+ },
+ meta: {
+ name: brunoCollection.name
+ }
+ };
+
return brunoCollection;
} catch (err) {
if (!(err instanceof Error)) {
diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js
index b05fe0a9a..b40315854 100644
--- a/packages/bruno-converters/src/postman/postman-to-bruno.js
+++ b/packages/bruno-converters/src/postman/postman-to-bruno.js
@@ -271,7 +271,6 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
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)) {
@@ -336,8 +335,9 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
folderMap[folderName] = brunoFolderItem;
} else if (i.request) {
- if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
- console.warn('Unexpected request.method', i?.request?.method);
+ const method = i?.request?.method?.toUpperCase();
+ if (!method || typeof method !== 'string' || !method.trim()) {
+ console.warn('Missing or invalid request.method', method);
return;
}
@@ -359,7 +359,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
seq: index + 1,
request: {
url: url,
- method: i?.request?.method?.toUpperCase(),
+ method: method,
auth: {
mode: 'inherit',
basic: null,
diff --git a/packages/bruno-converters/src/utils/jscode-shift-translator.js b/packages/bruno-converters/src/utils/jscode-shift-translator.js
index 405a8d540..14450e054 100644
--- a/packages/bruno-converters/src/utils/jscode-shift-translator.js
+++ b/packages/bruno-converters/src/utils/jscode-shift-translator.js
@@ -350,6 +350,9 @@ function translateCode(code) {
// Process all transformations in a single pass
processTransformations(ast, transformedNodes);
+ // Handle legacy Postman global APIs
+ handleLegacyGlobalAPIs(ast, transformedNodes, code);
+
// Handle special Postman syntax patterns
handleTestsBracketNotation(ast);
@@ -787,5 +790,102 @@ function handleTestsBracketNotation(ast) {
});
}
+/**
+ * Handle legacy Postman global API transformations
+ * This function processes legacy Postman globals like responseBody, responseHeaders, responseTime
+ * while preserving user-defined variables with the same names
+ *
+ * @param {Object} ast - jscodeshift AST
+ * @param {Set} transformedNodes - Set of already transformed nodes
+ * @param {string} code - The original Postman script code
+ */
+function handleLegacyGlobalAPIs(ast, transformedNodes, code) {
+ // regex check before the ast traversal
+ const legacyGlobalRegex = /responseBody|responseHeaders|responseTime/;
+
+ if (!legacyGlobalRegex.test(code)) {
+ return;
+ }
+
+ // Check for variable declarations with legacy global names - track which ones have conflicts
+ const conflictingNames = new Set();
+
+ // Check variable declarations
+ ast.find(j.VariableDeclarator).forEach(path => {
+ if (path.value.id.type === 'Identifier') {
+ const varName = path.value.id.name;
+ if (legacyGlobalRegex.test(varName)) {
+ conflictingNames.add(varName);
+ }
+ }
+ });
+
+ // Handle JSON.parse(responseBody) → res.getBody()
+ // Only transform if responseBody doesn't have a user variable conflict
+ if (!conflictingNames.has('responseBody')) {
+ ast.find(j.CallExpression).forEach(path => {
+ if (transformedNodes.has(path.node)) return;
+
+ const callExpr = path.value;
+ if (callExpr.callee.type === 'MemberExpression' && callExpr.callee.object.name === 'JSON' && callExpr.callee.property.name === 'parse') {
+ const args = callExpr.arguments;
+
+ // Check if the argument is 'responseBody'
+ if (args.length > 0 && args[0].type === 'Identifier' && args[0].name === 'responseBody') {
+ // Replace JSON.parse(responseBody) with res.getBody()
+ j(path).replaceWith(j.identifier('res.getBody()'));
+ transformedNodes.add(path.node);
+ }
+ }
+ });
+ }
+
+ // Handle standalone legacy Postman global variables
+ const legacyGlobals = [
+ { name: 'responseBody', replacement: 'res.getBody()' },
+ { name: 'responseHeaders', replacement: 'res.getHeaders()' },
+ { name: 'responseTime', replacement: 'res.getResponseTime()' }
+ ];
+
+ legacyGlobals.forEach(({ name, replacement }) => {
+ // Skip transformation if this name has a user variable conflict
+ if (conflictingNames.has(name)) {
+ return;
+ }
+
+ ast.find(j.Identifier, { name }).forEach(path => {
+ if (transformedNodes.has(path.node)) return;
+
+ // Only transform identifiers that are being used as values, not as variable names
+ const parent = path.parent.value;
+
+ // Skip if this is part of a variable declaration (const responseBody = ...)
+ if (parent.type === 'VariableDeclarator' && parent.id === path.node) {
+ return; // Keep unchanged
+ }
+
+ // Skip if this is part of an assignment (responseBody = ...)
+ if (parent.type === 'AssignmentExpression' && parent.left === path.node) {
+ return; // Keep unchanged
+ }
+
+ // Skip if this is part of a function parameter
+ if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression') {
+ return; // Keep unchanged
+ }
+
+ // Skip if this is part of an object property
+ if (parent.type === 'Property' && (parent.key === path.node || parent.value === path.node)) {
+ return; // Keep unchanged
+ }
+
+ // Transform all other references (including function call arguments)
+ // This will transform console.log(responseBody) → console.log(res.getBody())
+ j(path).replaceWith(j.identifier(replacement));
+ transformedNodes.add(path.node);
+ });
+ });
+}
+
export { getMemberExpressionString };
export default translateCode;
\ No newline at end of file
diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-auth.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-auth.spec.js
new file mode 100644
index 000000000..f999cdfae
--- /dev/null
+++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-auth.spec.js
@@ -0,0 +1,143 @@
+import { describe, it, expect } from '@jest/globals';
+import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
+
+describe('openapi-to-bruno auth enhancements', () => {
+ it('maps HTTP Digest scheme to digest auth on the request', () => {
+ const spec = `
+openapi: 3.0.3
+info:
+ title: Digest API
+ version: '1.0'
+components:
+ securitySchemes:
+ DigestAuth:
+ type: http
+ scheme: digest
+paths:
+ /secure:
+ get:
+ security:
+ - DigestAuth: []
+ responses:
+ '200': { description: OK }
+servers:
+ - url: https://example.com
+`;
+ const collection = openApiToBruno(spec);
+ const req = collection.items[0];
+ expect(req.request.auth.mode).toBe('digest');
+ expect(req.request.auth.digest).toEqual({ username: '{{username}}', password: '{{password}}' });
+ });
+
+ it('maps apiKey in query and injects query param', () => {
+ const spec = `
+openapi: 3.0.3
+info:
+ title: Query API-Key
+ version: '1.0'
+components:
+ securitySchemes:
+ ApiKeyQuery:
+ type: apiKey
+ in: query
+ name: api_key
+paths:
+ /search:
+ get:
+ security:
+ - ApiKeyQuery: []
+ parameters:
+ - in: query
+ name: q
+ schema: { type: string }
+ responses:
+ '200': { description: OK }
+servers:
+ - url: https://example.com
+`;
+ const collection = openApiToBruno(spec);
+ const req = collection.items[0];
+ expect(req.request.auth.mode).toBe('apikey');
+ expect(req.request.auth.apikey.placement).toBe('queryparams');
+ const hasQueryParam = req.request.params.some(p => p.name === 'api_key' && p.type === 'query');
+ expect(hasQueryParam).toBe(true);
+ });
+
+ it('maps apiKey in cookie and treats it as a header', () => {
+ const spec = `
+openapi: 3.0.3
+info:
+ title: Cookie API-Key
+ version: '1.0'
+components:
+ securitySchemes:
+ ApiKeyCookie:
+ type: apiKey
+ in: cookie
+ name: DEMO_API_KEY
+paths:
+ /favorites:
+ get:
+ security:
+ - ApiKeyCookie: []
+ responses:
+ '200': { description: OK }
+servers:
+ - url: https://example.com
+`;
+ const { items: [req] } = openApiToBruno(spec);
+ expect(req.request.auth.mode).toBe('apikey');
+ expect(req.request.auth.apikey.placement).toBe('header');
+ const apiKeyHeader = req.request.headers.find(h => h.name === 'DEMO_API_KEY');
+ expect(apiKeyHeader).toBeDefined();
+ expect(apiKeyHeader.value).toBe('{{apiKey}}');
+ });
+
+ it('maps OAuth2 authorizationCode flow to oauth2 grantType authorization_code', () => {
+ const spec = `
+openapi: 3.0.3
+info:
+ title: OAuth2 AuthCode
+ version: '1.0'
+components:
+ securitySchemes:
+ OAuthAuthCode:
+ type: oauth2
+ flows:
+ authorizationCode:
+ authorizationUrl: https://auth.example.com/authorize
+ tokenUrl: https://auth.example.com/token
+paths:
+ /orders:
+ get:
+ security:
+ - OAuthAuthCode: []
+ responses:
+ '200': { description: OK }
+servers:
+ - url: https://example.com
+`;
+ const { items: [req] } = openApiToBruno(spec);
+ expect(req.request.auth.mode).toBe('oauth2');
+ expect(req.request.auth.oauth2.grantType).toBe('authorization_code');
+ });
+
+ it('sets auth mode to inherit when operation security is explicitly empty', () => {
+ const spec = `
+openapi: 3.0.3
+info:
+ title: Public Endpoint
+ version: '1.0'
+paths:
+ /public:
+ get:
+ security: []
+ responses:
+ '200': { description: OK }
+servers:
+ - url: https://example.com
+`;
+ const { items: [req] } = openApiToBruno(spec);
+ expect(req.request.auth.mode).toBe('inherit');
+ });
+});
diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js
new file mode 100644
index 000000000..489bf734a
--- /dev/null
+++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js
@@ -0,0 +1,299 @@
+import translateCode from '../../../../src/utils/jscode-shift-translator.js';
+
+describe('Legacy Postman API Translation', () => {
+ describe('handleLegacyGlobalAPIs - No Conflicts', () => {
+ test('should translate responseBody when no user variables exist', () => {
+ const input = `
+ const data = JSON.parse(responseBody);
+`;
+
+ const result = translateCode(input);
+ const expected = `
+ const data = res.getBody();
+`;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should translate responseHeaders when no user variables exist', () => {
+ const input = `
+ console.log(responseHeaders);
+ const headers = responseHeaders;
+ `;
+
+ const result = translateCode(input);
+
+ expect(result).toContain('res.getHeaders()');
+ expect(result).not.toContain('responseHeaders');
+ });
+
+ test('should translate responseTime when no user variables exist', () => {
+ const input = `
+ console.log(responseTime);
+ const time = responseTime;
+ `;
+
+ const result = translateCode(input);
+
+ expect(result).toContain('res.getResponseTime()');
+ expect(result).not.toContain('responseTime');
+ });
+
+ test('should translate JSON.parse(responseBody) when no user variables exist', () => {
+ const input = `
+ const data = JSON.parse(responseBody);
+ console.log(data);
+ `;
+
+ const result = translateCode(input);
+
+ expect(result).toContain('res.getBody()');
+ expect(result).not.toContain('JSON.parse(responseBody)');
+ expect(result).not.toContain('responseBody');
+ });
+
+ test('should translate JSON.parse(responseBody) usage without assignment when no user variables exist', () => {
+ const input = `
+ console.log(JSON.parse(responseBody));
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ console.log(res.getBody());
+ `;
+
+ expect(result).toContain(expected);
+ });
+
+ test('should translate all legacy APIs when no conflicts exist', () => {
+ const input = `
+ const data = JSON.parse(responseBody);
+ const headers = responseHeaders;
+ const time = responseTime;
+
+ console.log(data, headers, time);
+ `;
+
+ const result = translateCode(input);
+
+ expect(result).toContain('res.getBody()');
+ expect(result).toContain('res.getHeaders()');
+ expect(result).toContain('res.getResponseTime()');
+ expect(result).not.toContain('responseBody');
+ expect(result).not.toContain('responseHeaders');
+ expect(result).not.toContain('responseTime');
+ });
+ });
+
+ describe('handleLegacyGlobalAPIs - With Conflicts', () => {
+ test('should NOT translate responseBody when user variable exists', () => {
+ const input = `
+ const responseBody = pm.response.json();
+ console.log(responseBody);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseBody = res.getBody();
+ console.log(responseBody);
+ `;
+
+ // pm.response.json() should be transformed to res.getBody() (Postman API transformation)
+ expect(result).toEqual(expected);
+ });
+
+ test('should NOT translate responseHeaders when user variable exists', () => {
+ const input = `
+ const responseHeaders = pm.response.headers;
+ console.log(responseHeaders);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseHeaders = res.getHeaders();
+ console.log(responseHeaders);
+ `;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should NOT translate responseTime when user variable exists', () => {
+ const input = `
+ const responseTime = pm.response.responseTime;
+ console.log(responseTime);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseTime = res.getResponseTime();
+ console.log(responseTime);
+ `;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should NOT translate JSON.parse(responseBody) when user variable exists', () => {
+ const input = `
+ const responseBody = pm.response.json();
+ const data = JSON.parse(responseBody);
+ console.log(data);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseBody = res.getBody();
+ const data = JSON.parse(responseBody);
+ console.log(data);
+ `;
+
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('handleLegacyGlobalAPIs - Partial Conflicts', () => {
+ test('should translate non-conflicting APIs when some conflicts exist', () => {
+ const input = `
+ const responseBody = pm.response.json();
+ console.log(responseBody);
+ console.log(responseHeaders);
+ console.log(responseTime);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseBody = res.getBody();
+ console.log(responseBody);
+ console.log(res.getHeaders());
+ console.log(res.getResponseTime());
+ `;
+
+ expect(result).toEqual(expected);
+
+ });
+
+ test('should translate JSON.parse(responseBody) only when no conflict exists', () => {
+ const input = `
+ const responseHeaders = pm.response.headers;
+ const data = JSON.parse(responseBody);
+ console.log(responseHeaders);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const responseHeaders = res.getHeaders();
+ const data = res.getBody();
+ console.log(responseHeaders);
+ `;
+
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('handleLegacyGlobalAPIs - Edge Cases', () => {
+ test.skip('should handle function parameters with legacy names', () => {
+ const input = `
+ function test(responseBody) {
+ console.log(responseBody);
+ console.log(responseHeaders);
+ }
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ function test(responseBody) {
+ console.log(responseBody);
+ console.log(res.getHeaders());
+ }
+ `;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should handle object properties with legacy names', () => {
+ const input = `
+ const config = {
+ responseBody: 'custom',
+ responseHeaders: 'custom'
+ };
+ console.log(responseTime);
+ `;
+
+ const result = translateCode(input);
+
+ const expected = `
+ const config = {
+ responseBody: 'custom',
+ responseHeaders: 'custom'
+ };
+ console.log(res.getResponseTime());
+ `;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should handle assignments with legacy names', () => {
+ const input = `
+ responseBody = 'new value';
+ responseHeaders = 'new headers';
+ console.log(responseTime);
+ `;
+
+ const result = translateCode(input);
+
+ const expected = `
+ responseBody = 'new value';
+ responseHeaders = 'new headers';
+ console.log(res.getResponseTime());
+ `;
+
+ expect(result).toEqual(expected);
+ });
+
+ test('should handle mixed usage patterns', () => {
+ const input = `
+ const responseBody = pm.response.json();
+ const data = JSON.parse(responseBody);
+ console.log(responseHeaders);
+ console.log(responseTime);
+
+ function test(data) {
+ console.log(responseBody);
+ console.log(responseHeaders);
+ }
+ `;
+
+ const result = translateCode(input);
+
+ const expected = `
+ const responseBody = res.getBody();
+ const data = JSON.parse(responseBody);
+ console.log(res.getHeaders());
+ console.log(res.getResponseTime());
+
+ function test(data) {
+ console.log(responseBody);
+ console.log(res.getHeaders());
+ }
+ `;
+
+ expect(result).toEqual(expected);
+ });
+ });
+
+ describe('handleLegacyGlobalAPIs - No Legacy APIs', () => {
+ test('should not modify code when no legacy APIs are present', () => {
+ const input = `
+ const data = { name: 'test' };
+ console.log(data.name);
+ `;
+
+ const result = translateCode(input);
+ const expected = `
+ const data = { name: 'test' };
+ console.log(data.name);
+ `;
+
+ expect(result).toEqual(expected);
+ });
+ });
+});
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 0d2e92cbd..364860bd5 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -174,13 +174,7 @@ app.on('ready', async () => {
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
});
- globalShortcut.register('CommandOrControl+M', () => {
- mainWindow.minimize();
- });
- globalShortcut.register('CommandOrControl+H', () => {
- mainWindow.minimize();
- });
mainWindow.webContents.on('did-finish-load', async () => {
let ogSend = mainWindow.webContents.send;
diff --git a/packages/bruno-electron/src/ipc/network/axios-instance.js b/packages/bruno-electron/src/ipc/network/axios-instance.js
index e86d06fea..3b3cc2b14 100644
--- a/packages/bruno-electron/src/ipc/network/axios-instance.js
+++ b/packages/bruno-electron/src/ipc/network/axios-instance.js
@@ -297,7 +297,7 @@ function makeAxiosInstance({
}
if (preferencesUtil.shouldStoreCookies()) {
- saveCookies(redirectUrl, error.response.headers);
+ saveCookies(error.config.url, error.response.headers);
}
// Create a new request config for the redirect
diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js
index cc3ad9add..c6e95e452 100644
--- a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js
+++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js
@@ -11,118 +11,7 @@ const { getProcessEnvVars } = require('../../store/process-env');
const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2');
const { interpolateString } = require('./interpolate-string');
const path = require('node:path');
-
-const setGrpcAuthHeaders = (grpcRequest, request, collectionRoot) => {
- const collectionAuth = get(collectionRoot, 'request.auth');
- if (collectionAuth && request.auth?.mode === 'inherit') {
- if (collectionAuth.mode === 'basic') {
- grpcRequest.basicAuth = {
- username: get(collectionAuth, 'basic.username'),
- password: get(collectionAuth, 'basic.password')
- };
- }
-
- if (collectionAuth.mode === 'bearer') {
- grpcRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
- }
-
- if (collectionAuth.mode === 'apikey') {
- grpcRequest.headers[collectionAuth.apikey?.key] = collectionAuth.apikey?.value;
-
- }
-
- if (collectionAuth.mode === 'oauth2') {
- const grantType = get(collectionAuth, 'oauth2.grantType');
-
- if (grantType === 'client_credentials') {
- grpcRequest.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') {
- grpcRequest.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 (request.auth && request.auth.mode !== 'inherit') {
- if (request.auth.mode === 'basic') {
- grpcRequest.basicAuth = {
- username: get(request, 'auth.basic.username'),
- password: get(request, 'auth.basic.password')
- };
- }
-
- if (request.auth.mode === 'bearer') {
- grpcRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
- }
-
- if (request.auth.mode === 'oauth2') {
- const grantType = get(request, 'auth.oauth2.grantType');
-
-
- if (grantType === 'client_credentials') {
- grpcRequest.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') {
- grpcRequest.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')
- };
- } else if (grantType === 'authorization_code') {
- grpcRequest.oauth2 = {
- grantType,
- ...get(request, 'auth.oauth2')
- };
- }
- }
-
- if (request.auth.mode === 'apikey') {
- grpcRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
- }
- }
-
-
- return grpcRequest;
-}
+const { setAuthHeaders } = require('./prepare-request');
const prepareRequest = async (item, collection, environment, runtimeVariables, certsAndProxyConfig = {}) => {
const request = item.draft ? item.draft.request : item.request;
@@ -182,7 +71,7 @@ const prepareRequest = async (item, collection, environment, runtimeVariables, c
oauth2CredentialVariables: request.oauth2CredentialVariables,
}
- grpcRequest = setGrpcAuthHeaders(grpcRequest, request, collectionRoot);
+ grpcRequest = setAuthHeaders(grpcRequest, request, collectionRoot);
if (grpcRequest.oauth2) {
let requestCopy = cloneDeep(grpcRequest);
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 585be187c..3c7f68727 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -1452,7 +1452,8 @@ const registerNetworkIpc = (mainWindow) => {
type: 'testrun-ended',
collectionUid,
folderUid,
- statusText: 'collection run was terminated!'
+ statusText: 'collection run was terminated!',
+ runCompletionTime: new Date().toISOString(),
});
break;
}
@@ -1481,7 +1482,8 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
collectionUid,
- folderUid
+ folderUid,
+ runCompletionTime: new Date().toISOString(),
});
} catch (error) {
console.log('error', error);
@@ -1490,6 +1492,7 @@ const registerNetworkIpc = (mainWindow) => {
type: 'testrun-ended',
collectionUid,
folderUid,
+ runCompletionTime: new Date().toISOString(),
error: error && !error.isCancel ? error : null
});
}
diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js
index 375422164..09d03c85f 100644
--- a/packages/bruno-electron/src/ipc/network/prepare-request.js
+++ b/packages/bruno-electron/src/ipc/network/prepare-request.js
@@ -43,8 +43,8 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'wsse':
- const username = get(request, 'auth.wsse.username', '');
- const password = get(request, 'auth.wsse.password', '');
+ const username = get(collectionAuth, 'wsse.username', '');
+ const password = get(collectionAuth, 'wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('hex');
@@ -193,7 +193,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
- refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
+ refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
@@ -215,7 +215,7 @@ 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'),
+ refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
@@ -251,7 +251,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
axiosRequest.oauth2 = {
grantType: grantType,
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
- refreshTokenUrl: get(collectionAuth, 'oauth2.refreshTokenUrl'),
+ refreshTokenUrl: get(request, 'auth.oauth2.refreshTokenUrl'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
diff --git a/packages/bruno-electron/tests/prepare-request.test.js b/packages/bruno-electron/tests/prepare-request.test.js
new file mode 100644
index 000000000..9830b45c3
--- /dev/null
+++ b/packages/bruno-electron/tests/prepare-request.test.js
@@ -0,0 +1,959 @@
+const crypto = require('node:crypto');
+
+// Mock crypto.randomBytes to return predictable values for testing
+jest.mock('node:crypto', () => ({
+ ...jest.requireActual('node:crypto'),
+ randomBytes: jest.fn(() => Buffer.from('1234567890abcdef', 'hex'))
+}));
+
+// Mock the lodash get function with a more sophisticated mock
+const mockGet = jest.fn();
+jest.mock('lodash', () => ({
+ get: mockGet,
+ each: jest.fn(),
+ filter: jest.fn(),
+ find: jest.fn()
+}));
+
+// Import the function to test
+const { setAuthHeaders } = require('../src/ipc/network/prepare-request');
+
+describe('setAuthHeaders', () => {
+ let mockAxiosRequest;
+ let mockRequest;
+ let mockCollectionRoot;
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Reset crypto mock to return predictable values
+ crypto.randomBytes.mockReturnValue(Buffer.from('1234567890abcdef', 'hex'));
+
+ // Setup default mock objects
+ mockAxiosRequest = {
+ headers: {}
+ };
+
+ mockRequest = {
+ auth: {
+ mode: 'none'
+ }
+ };
+
+ mockCollectionRoot = {
+ request: {
+ auth: null
+ }
+ };
+
+ // Setup a more sophisticated mock for lodash get function
+ mockGet.mockImplementation((obj, path, defaultValue) => {
+ if (!obj) return defaultValue;
+
+ const keys = path.split('.');
+ let current = obj;
+
+ for (const key of keys) {
+ if (current && typeof current === 'object' && key in current) {
+ current = current[key];
+ } else {
+ return defaultValue;
+ }
+ }
+
+ return current;
+ });
+ });
+
+ describe('Collection-level authentication inheritance', () => {
+ test('should inherit AWS v4 authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'awsv4',
+ awsv4: {
+ accessKeyId: 'test-access-key',
+ secretAccessKey: 'test-secret-key',
+ sessionToken: 'test-session-token',
+ service: 's3',
+ region: 'us-east-1',
+ profileName: 'default'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.awsv4config).toEqual({
+ accessKeyId: 'test-access-key',
+ secretAccessKey: 'test-secret-key',
+ sessionToken: 'test-session-token',
+ service: 's3',
+ region: 'us-east-1',
+ profileName: 'default'
+ });
+ });
+
+ test('should inherit basic authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'basic',
+ basic: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.basicAuth).toEqual({
+ username: 'testuser',
+ password: 'testpass'
+ });
+ });
+
+ test('should inherit bearer authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'test-token'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['Authorization']).toBe('Bearer test-token');
+ });
+
+ test('should inherit digest authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'digest',
+ digest: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.digestConfig).toEqual({
+ username: 'testuser',
+ password: 'testpass'
+ });
+ });
+
+ test('should inherit NTLM authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'ntlm',
+ ntlm: {
+ username: 'testuser',
+ password: 'testpass',
+ domain: 'testdomain'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.ntlmConfig).toEqual({
+ username: 'testuser',
+ password: 'testpass',
+ domain: 'testdomain'
+ });
+ });
+
+ test('should inherit WSSE authentication from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'wsse',
+ wsse: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="testuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
+ });
+
+ test('should inherit API key authentication from collection (header placement)', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'X-API-Key',
+ value: 'test-api-key',
+ placement: 'header'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['X-API-Key']).toBe('test-api-key');
+ });
+
+ test('should inherit API key authentication from collection (query params placement)', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'api_key',
+ value: 'test-api-key',
+ placement: 'queryparams'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.apiKeyAuthValueForQueryParams).toEqual({
+ key: 'api_key',
+ value: 'test-api-key',
+ placement: 'queryparams'
+ });
+ });
+
+ test('should skip API key authentication when key is empty', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: '',
+ value: 'test-api-key',
+ placement: 'header'
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['']).toBeUndefined();
+ });
+ });
+
+ describe('OAuth2 authentication inheritance', () => {
+ test('should inherit OAuth2 password grant from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'password',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ username: 'testuser',
+ password: 'testpass',
+ clientId: 'test-client',
+ clientSecret: 'test-secret',
+ scope: 'read write',
+ credentialsPlacement: 'body',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'password',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ username: 'testuser',
+ password: 'testpass',
+ clientId: 'test-client',
+ clientSecret: 'test-secret',
+ scope: 'read write',
+ credentialsPlacement: 'body',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should inherit OAuth2 authorization_code grant from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'authorization_code',
+ callbackUrl: 'https://example.com/callback',
+ authorizationUrl: 'https://example.com/auth',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ clientId: 'test-client',
+ clientSecret: 'test-secret',
+ scope: 'read write',
+ state: 'random-state',
+ pkce: true,
+ credentialsPlacement: 'body',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'authorization_code',
+ callbackUrl: 'https://example.com/callback',
+ authorizationUrl: 'https://example.com/auth',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ clientId: 'test-client',
+ scope: 'read write',
+ state: 'random-state',
+ pkce: true,
+ credentialsPlacement: 'body',
+ clientSecret: 'test-secret',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should inherit OAuth2 implicit grant from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'implicit',
+ callbackUrl: 'https://example.com/callback',
+ authorizationUrl: 'https://example.com/auth',
+ clientId: 'test-client',
+ scope: 'read write',
+ state: 'random-state',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'implicit',
+ callbackUrl: 'https://example.com/callback',
+ authorizationUrl: 'https://example.com/auth',
+ clientId: 'test-client',
+ scope: 'read write',
+ state: 'random-state',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should inherit OAuth2 client_credentials grant from collection', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ clientId: 'test-client',
+ clientSecret: 'test-secret',
+ scope: 'read write',
+ credentialsPlacement: 'body',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ mockRequest.auth.mode = 'inherit';
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://example.com/token',
+ refreshTokenUrl: 'https://example.com/refresh',
+ clientId: 'test-client',
+ clientSecret: 'test-secret',
+ scope: 'read write',
+ credentialsPlacement: 'body',
+ credentialsId: 'test-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+ });
+
+ describe('Request-level authentication (overrides collection)', () => {
+ test('should set AWS v4 authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'awsv4',
+ awsv4: {
+ accessKeyId: 'test-access-key',
+ secretAccessKey: 'test-secret-key',
+ sessionToken: 'test-session-token',
+ service: 's3',
+ region: 'us-east-1',
+ profileName: 'default'
+ }
+ }
+ mockRequest.auth = {
+ mode: 'awsv4',
+ awsv4: {
+ accessKeyId: 'request-access-key',
+ secretAccessKey: 'request-secret-key',
+ sessionToken: 'request-session-token',
+ service: 's3',
+ region: 'us-west-2',
+ profileName: 'production'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.awsv4config).toEqual({
+ accessKeyId: 'request-access-key',
+ secretAccessKey: 'request-secret-key',
+ sessionToken: 'request-session-token',
+ service: 's3',
+ region: 'us-west-2',
+ profileName: 'production'
+ });
+ });
+
+ test('should set basic authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'basic',
+ basic: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'basic',
+ basic: {
+ username: 'requestuser',
+ password: 'requestpass'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.basicAuth).toEqual({
+ username: 'requestuser',
+ password: 'requestpass'
+ });
+ });
+
+ test('should set bearer authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'test-token'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'request-token'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['Authorization']).toBe('Bearer request-token');
+ });
+
+ test('should set digest authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'digest',
+ digest: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'digest',
+ digest: {
+ username: 'requestuser',
+ password: 'requestpass'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.digestConfig).toEqual({
+ username: 'requestuser',
+ password: 'requestpass'
+ });
+ });
+
+ test('should set NTLM authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'ntlm',
+ ntlm: {
+ username: 'testuser',
+ password: 'testpass',
+ domain: 'testdomain'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'ntlm',
+ ntlm: {
+ username: 'requestuser',
+ password: 'requestpass',
+ domain: 'requestdomain'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.ntlmConfig).toEqual({
+ username: 'requestuser',
+ password: 'requestpass',
+ domain: 'requestdomain'
+ });
+ });
+
+ test('should set WSSE authentication at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'wsse',
+ wsse: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'wsse',
+ wsse: {
+ username: 'requestuser',
+ password: 'requestpass'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['X-WSSE']).toMatch(/UsernameToken Username="requestuser", PasswordDigest="[^"]+", Nonce="1234567890abcdef", Created="[^"]+"/);
+ });
+
+ test('should set API key authentication at request level (header placement)', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'X-Request-API-Key',
+ value: 'test-api-key',
+ placement: 'header'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'X-Request-API-Key',
+ value: 'request-api-key',
+ placement: 'header'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.headers['X-Request-API-Key']).toBe('request-api-key');
+ });
+
+ test('should set API key authentication at request level (query params placement)', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'X-Request-API-Key',
+ value: 'test-api-key',
+ placement: 'header'
+ }
+ };
+ mockRequest.auth = {
+ mode: 'apikey',
+ apikey: {
+ key: 'request_api_key',
+ value: 'request-api-key',
+ placement: 'queryparams'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.apiKeyAuthValueForQueryParams).toEqual({
+ key: 'request_api_key',
+ value: 'request-api-key',
+ placement: 'queryparams'
+ });
+ });
+
+ test('should set OAuth2 password grant at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'password',
+ accessTokenUrl: 'https://collection.com/token',
+ refreshTokenUrl: 'https://collection.com/refresh',
+ username: 'collectionuser',
+ password: 'collectionpass',
+ clientId: 'collection-client',
+ clientSecret: 'collection-secret',
+ scope: 'read',
+ credentialsPlacement: 'header',
+ credentialsId: 'collection-credentials',
+ tokenPlacement: 'query',
+ tokenHeaderPrefix: 'Token',
+ tokenQueryKey: 'token',
+ autoFetchToken: false,
+ autoRefreshToken: false,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'password',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ username: 'requestuser',
+ password: 'requestpass',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ credentialsPlacement: 'header',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'query',
+ tokenHeaderPrefix: 'Token',
+ tokenQueryKey: 'token',
+ autoFetchToken: false,
+ autoRefreshToken: false,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'password',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ username: 'requestuser',
+ password: 'requestpass',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ credentialsPlacement: 'header',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'query',
+ tokenHeaderPrefix: 'Token',
+ tokenQueryKey: 'token',
+ autoFetchToken: false,
+ autoRefreshToken: false,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should set OAuth2 authorization_code grant at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'password',
+ callbackUrl: 'https://collection.com/callback',
+ authorizationUrl: 'https://collection.com/auth',
+ accessTokenUrl: 'https://collection.com/token',
+ refreshTokenUrl: 'https://collection.com/refresh',
+ username: 'collectionuser',
+ password: 'collectionpass',
+ clientId: 'collection-client',
+ clientSecret: 'collection-secret',
+ }
+ };
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'authorization_code',
+ callbackUrl: 'https://request.com/callback',
+ authorizationUrl: 'https://request.com/auth',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ state: 'request-state',
+ pkce: false,
+ credentialsPlacement: 'body',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'authorization_code',
+ callbackUrl: 'https://request.com/callback',
+ authorizationUrl: 'https://request.com/auth',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ state: 'request-state',
+ pkce: false,
+ credentialsPlacement: 'body',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should set OAuth2 implicit grant at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'implicit',
+ callbackUrl: 'https://collection.com/callback',
+ authorizationUrl: 'https://collection.com/auth',
+ clientId: 'collection-client',
+ scope: 'read',
+ state: 'collection-state',
+ credentialsId: 'collection-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'implicit',
+ callbackUrl: 'https://request.com/callback',
+ authorizationUrl: 'https://request.com/auth',
+ clientId: 'request-client',
+ scope: 'read',
+ state: 'request-state',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'query',
+ tokenHeaderPrefix: 'Token',
+ tokenQueryKey: 'token',
+ autoFetchToken: false,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'implicit',
+ callbackUrl: 'https://request.com/callback',
+ authorizationUrl: 'https://request.com/auth',
+ clientId: 'request-client',
+ credentialsId: 'request-credentials',
+ scope: 'read',
+ state: 'request-state',
+ tokenPlacement: 'query',
+ tokenHeaderPrefix: 'Token',
+ tokenQueryKey: 'token',
+ autoFetchToken: false,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+
+ test('should set OAuth2 client_credentials grant at request level', () => {
+ mockCollectionRoot.request.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://collection.com/token',
+ refreshTokenUrl: 'https://collection.com/refresh',
+ clientId: 'collection-client',
+ clientSecret: 'collection-secret',
+ scope: 'read',
+ credentialsPlacement: 'body',
+ credentialsId: 'collection-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ credentialsPlacement: 'body',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.oauth2).toEqual({
+ grantType: 'client_credentials',
+ accessTokenUrl: 'https://request.com/token',
+ refreshTokenUrl: 'https://request.com/refresh',
+ clientId: 'request-client',
+ clientSecret: 'request-secret',
+ scope: 'read',
+ credentialsPlacement: 'body',
+ credentialsId: 'request-credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
+ autoFetchToken: true,
+ autoRefreshToken: true,
+ additionalParameters: { authorization: [], token: [], refresh: [] }
+ });
+ });
+ });
+
+ describe('Edge cases and error handling', () => {
+ test('should handle missing collection auth gracefully', () => {
+ mockCollectionRoot.request.auth = null;
+ mockRequest.auth = {
+ mode: 'basic',
+ basic: {
+ username: 'testuser',
+ password: 'testpass'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result.basicAuth).toEqual({
+ username: 'testuser',
+ password: 'testpass'
+ });
+ });
+
+ test('should handle missing request auth gracefully', () => {
+ mockRequest.auth = null;
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.headers).toEqual({});
+ });
+
+ test('should handle missing auth mode gracefully', () => {
+ mockRequest.auth = {};
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.headers).toEqual({});
+ });
+
+ test('should handle unknown auth mode gracefully', () => {
+ mockRequest.auth = {
+ mode: 'unknown'
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.headers).toEqual({});
+ });
+
+ test('should handle missing OAuth2 grant type gracefully', () => {
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {}
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.oauth2).toBeUndefined();
+ });
+
+ test('should handle unknown OAuth2 grant type gracefully', () => {
+ mockRequest.auth = {
+ mode: 'oauth2',
+ oauth2: {
+ grantType: 'unknown_grant'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.oauth2).toBeUndefined();
+ });
+
+ test('should return the modified axiosRequest object', () => {
+ mockRequest.auth = {
+ mode: 'bearer',
+ bearer: {
+ token: 'test-token'
+ }
+ };
+
+ const result = setAuthHeaders(mockAxiosRequest, mockRequest, mockCollectionRoot);
+
+ expect(result).toBe(mockAxiosRequest);
+ expect(result.headers['Authorization']).toBe('Bearer test-token');
+ });
+ });
+});
diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts
index f6a4a3da0..995be06b4 100644
--- a/packages/bruno-filestore/src/formats/bru/index.ts
+++ b/packages/bruno-filestore/src/formats/bru/index.ts
@@ -44,8 +44,11 @@ export const bruRequestToJson = (data: string | any, parsed: boolean = false): a
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'meta.tags', []),
request: {
+ // Preserving special characters in custom methods. Using _.upperCase strips special characters.
method:
- requestType === 'grpc-request' ? _.get(json, 'grpc.method', '') : _.upperCase(_.get(json, 'http.method')),
+ requestType === 'grpc-request'
+ ? _.get(json, 'grpc.method', '')
+ : String(_.get(json, 'http.method') ?? '').toUpperCase(),
url: _.get(json, urlPath[requestType], urlPath.default),
headers: requestType === 'grpc-request' ? _.get(json, 'metadata', []) : _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
@@ -143,7 +146,8 @@ export const jsonRequestToBru = (json: any): string => {
// For HTTP and GraphQL requests, maintain the current structure
if (type === 'http' || type === 'graphql') {
bruJson.http = {
- method: _.lowerCase(_.get(json, 'request.method')),
+ // Preserve special characters in custom request methods. Avoid _.lowerCase which strips symbols.
+ method: String(_.get(json, 'request.method') ?? '').toLowerCase(),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 2b677a88f..2bcb0a7d9 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -125,6 +125,12 @@ class Bru {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
+ if (variableNameRegex.test(key) === false) {
+ throw new Error(
+ `Variable name: "${key}" contains invalid characters! Names must only contain alpha-numeric characters, "-", "_", "."`
+ );
+ }
+
// When persist is true, only string values are allowed
if (options?.persist && typeof value !== 'string') {
throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
@@ -133,7 +139,7 @@ class Bru {
this.envVariables[key] = value;
if (options?.persist) {
- this.persistentEnvVariables[key] = value
+ this.persistentEnvVariables[key] = value;
} else {
if (this.persistentEnvVariables[key]) {
delete this.persistentEnvVariables[key];
diff --git a/packages/bruno-js/tests/setEnvVar.spec.js b/packages/bruno-js/tests/setEnvVar.spec.js
new file mode 100644
index 000000000..d1929055c
--- /dev/null
+++ b/packages/bruno-js/tests/setEnvVar.spec.js
@@ -0,0 +1,74 @@
+const Bru = require('../src/bru');
+
+describe('Bru.setEnvVar', () => {
+ const makeBru = () =>
+ new Bru(
+ /* envVariables */ {},
+ /* runtimeVariables */ {},
+ /* processEnvVars */ {},
+ /* collectionPath */ '/',
+ /* historyLogger */ undefined,
+ /* setVisualizations */ undefined,
+ /* secretVariables */ {},
+ /* collectionVariables */ {},
+ /* folderVariables */ {},
+ /* requestVariables */ {},
+ /* globalEnvironmentVariables */ {},
+ /* oauth2CredentialVariables */ {},
+ /* iterationDetails */ {},
+ /* collectionName */ 'Test'
+ );
+
+ test('updates envVariables and does not mark persistent when persist=false', () => {
+ const bru = makeBru();
+ bru.setEnvVar('non_persist', 'value', { persist: false });
+ expect(bru.envVariables.non_persist).toBe('value');
+ expect(bru.persistentEnvVariables.non_persist).toBeUndefined();
+ });
+
+ test('updates envVariables and tracks persistent when persist=true (string only)', () => {
+ const bru = makeBru();
+ bru.setEnvVar('persist_me', 'value', { persist: true });
+ expect(bru.envVariables.persist_me).toBe('value');
+ expect(bru.persistentEnvVariables.persist_me).toBe('value');
+ });
+
+ test('updates envVariables when options are omitted (defaults to non-persistent)', () => {
+ const bru = makeBru();
+ bru.setEnvVar('no_options', 'value');
+ expect(bru.envVariables.no_options).toBe('value');
+ expect(bru.persistentEnvVariables.no_options).toBeUndefined();
+ });
+
+ test('throws when persist=true but value is not a string', () => {
+ const bru = makeBru();
+ expect(() => bru.setEnvVar('persist_me', 123, { persist: true })).toThrow(
+ /Persistent environment variables must be strings/
+ );
+ });
+
+ test('changing existing key to non-persistent removes prior persisted entry', () => {
+ const bru = makeBru();
+ bru.setEnvVar('same_key', 'old', { persist: true });
+ expect(bru.persistentEnvVariables.same_key).toBe('old');
+
+ bru.setEnvVar('same_key', 'new');
+ expect(bru.envVariables.same_key).toBe('new');
+ expect(bru.persistentEnvVariables.same_key).toBeUndefined();
+ });
+
+ test('changing existing key to persistent updates persisted value', () => {
+ const bru = makeBru();
+ bru.setEnvVar('same_key', 'old');
+ expect(bru.persistentEnvVariables.same_key).toBeUndefined();
+
+ bru.setEnvVar('same_key', 'new', { persist: true });
+ expect(bru.envVariables.same_key).toBe('new');
+ expect(bru.persistentEnvVariables.same_key).toBe('new');
+ });
+
+ test('validates key name - invalid characters are rejected', () => {
+ const bru = makeBru();
+ expect(() => bru.setEnvVar('invalid key', 'v')).toThrow(/contains invalid characters/);
+ });
+});
diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js
index 7916ca673..99008f286 100644
--- a/packages/bruno-lang/v2/src/bruToJson.js
+++ b/packages/bruno-lang/v2/src/bruToJson.js
@@ -56,7 +56,13 @@ const grammar = ohm.grammar(`Bru {
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
- pair = st* key st* ":" st* value st*
+ pair = st* (quoted_key | key) st* ":" st* value st*
+ disable_char = "~"
+ quote_char = "\\""
+ esc_char = "\\\\"
+ esc_quote_char = esc_char quote_char
+ quoted_key_char = ~(quote_char | esc_quote_char | nl) any
+ quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char
key = keychar*
value = list | multilinetextblock | valuechar*
@@ -80,10 +86,9 @@ const grammar = ohm.grammar(`Bru {
meta = "meta" dictionary
settings = "settings" dictionary
- http = get | post | put | delete | patch | options | head | connect | trace
+ http = get | post | put | delete | patch | options | head | connect | trace | httpcustom
grpc = "grpc" dictionary
ws = "ws" dictionary
-
get = "get" dictionary
post = "post" dictionary
put = "put" dictionary
@@ -93,6 +98,7 @@ const grammar = ohm.grammar(`Bru {
head = "head" dictionary
connect = "connect" dictionary
trace = "trace" dictionary
+ httpcustom = "http" dictionary
headers = "headers" dictionary
@@ -302,6 +308,14 @@ const sem = grammar.createSemantics().addAttribute('ast', {
res[key.ast] = value.ast ? value.ast.trim() : '';
return res;
},
+ esc_quote_char(_1, quote) {
+ // unescape
+ return quote.sourceString;
+ },
+ quoted_key(disabled, _1, chars, _2) {
+ // unquote
+ return (disabled ? disabled.sourceString : '') + chars.ast.join('');
+ },
key(chars) {
return chars.sourceString ? chars.sourceString.trim() : '';
},
@@ -365,6 +379,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
tagend(_1, _2) {
return '';
},
+ _terminal() {
+ return this.sourceString;
+ },
multilinetextblockdelimiter(_) {
return '';
},
@@ -473,6 +490,26 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ trace(_1, dictionary) {
+ return {
+ http: {
+ method: 'trace',
+ ...mapPairListToKeyValPair(dictionary.ast)
+ }
+ };
+ },
+ httpcustom(_1, dictionary) {
+ const dict = mapPairListToKeyValPair(dictionary.ast);
+ const method = dict.method;
+ const rest = { ...dict };
+ delete rest.method;
+ return {
+ http: {
+ method,
+ ...rest
+ }
+ };
+ },
query(_1, dictionary) {
return {
params: mapRequestParams(dictionary.ast, 'query')
diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js
index 7b3a97280..e226dc01f 100644
--- a/packages/bruno-lang/v2/src/jsonToBru.js
+++ b/packages/bruno-lang/v2/src/jsonToBru.js
@@ -4,6 +4,10 @@ const { indentString } = require('./utils');
const enabled = (items = [], key = 'enabled') => items.filter((item) => item[key]);
const disabled = (items = [], key = 'enabled') => items.filter((item) => !item[key]);
+const quoteKey = (key) => {
+ const quotableChars = [':', '"', '{', '}', ' '];
+ return quotableChars.some((char) => key.includes(char)) ? '"' + key.replaceAll('"', '\\"') + '"' : key;
+};
// remove the last line if two new lines are found
const stripLastLine = (text) => {
@@ -71,24 +75,24 @@ const jsonToBru = (json) => {
bru += '}\n\n';
}
- if (http && http.method) {
- bru += `${http.method} {
- url: ${http.url}`;
+ if (http?.method) {
+ const { method, url, body, auth } = http;
+ const standardMethods = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect']);
- if (http.body && http.body.length) {
- bru += `
- body: ${http.body}`;
+ const isStandard = standardMethods.has(method);
+
+ bru += isStandard ? `${method} {` : `http {\n method: ${method}`;
+ bru += `\n url: ${url}`;
+
+ if (body?.length) {
+ bru += `\n body: ${body}`;
}
- if (http.auth && http.auth.length) {
- bru += `
- auth: ${http.auth}`;
+ if (auth?.length) {
+ bru += `\n auth: ${auth}`;
}
- bru += `
-}
-
-`;
+ bru += `\n}\n\n`;
}
if (grpc && grpc.url) {
@@ -165,7 +169,7 @@ const jsonToBru = (json) => {
if (enabled(queryParams).length) {
bru += `\n${indentString(
enabled(queryParams)
- .map((item) => `${item.name}: ${item.value}`)
+ .map((item) => `${quoteKey(item.name)}: ${item.value}`)
.join('\n')
)}`;
}
@@ -173,7 +177,7 @@ const jsonToBru = (json) => {
if (disabled(queryParams).length) {
bru += `\n${indentString(
disabled(queryParams)
- .map((item) => `~${item.name}: ${item.value}`)
+ .map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.join('\n')
)}`;
}
@@ -195,7 +199,7 @@ const jsonToBru = (json) => {
if (enabled(headers).length) {
bru += `\n${indentString(
enabled(headers)
- .map((item) => `${item.name}: ${item.value}`)
+ .map((item) => `${quoteKey(item.name)}: ${item.value}`)
.join('\n')
)}`;
}
@@ -203,7 +207,7 @@ const jsonToBru = (json) => {
if (disabled(headers).length) {
bru += `\n${indentString(
disabled(headers)
- .map((item) => `~${item.name}: ${item.value}`)
+ .map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.join('\n')
)}`;
}
@@ -559,14 +563,14 @@ ${indentString(body.sparql)}
if (enabled(body.formUrlEncoded).length) {
const enabledValues = enabled(body.formUrlEncoded)
- .map((item) => `${item.name}: ${getValueString(item.value)}`)
+ .map((item) => `${quoteKey(item.name)}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(enabledValues)}\n`;
}
if (disabled(body.formUrlEncoded).length) {
const disabledValues = disabled(body.formUrlEncoded)
- .map((item) => `~${item.name}: ${getValueString(item.value)}`)
+ .map((item) => `~${quoteKey(item.name)}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(disabledValues)}\n`;
}
@@ -587,7 +591,7 @@ ${indentString(body.sparql)}
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'text') {
- return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`;
+ return `${enabled}${quoteKey(item.name)}: ${getValueString(item.value)}${contentType}`;
}
if (item.type === 'file') {
@@ -595,7 +599,7 @@ ${indentString(body.sparql)}
const filestr = filepaths.join('|');
const value = `@file(${filestr})`;
- return `${enabled}${item.name}: ${value}${contentType}`;
+ return `${enabled}${quoteKey(item.name)}: ${value}${contentType}`;
}
})
.join('\n')
diff --git a/packages/bruno-lang/v2/tests/custom-methods/custom-method.spec.js b/packages/bruno-lang/v2/tests/custom-methods/custom-method.spec.js
new file mode 100644
index 000000000..bea93c724
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/custom-method.spec.js
@@ -0,0 +1,60 @@
+const fs = require('fs');
+const path = require('path');
+const bruToJson = require('../../src/bruToJson');
+const jsonToBru = require('../../src/jsonToBru');
+
+describe('Custom Method Conversion Tests', () => {
+ const fixturesDir = path.join(__dirname, 'fixtures');
+
+ describe('parse (BRU to JSON)', () => {
+ it('should parse FETCH custom method from BRU to JSON', () => {
+ const input = fs.readFileSync(path.join(fixturesDir, 'custom-method.bru'), 'utf8');
+ const expected = require(path.join(fixturesDir, 'custom-method.json'));
+ const output = bruToJson(input);
+
+ expect(output).toEqual(expected);
+ });
+
+ it('should parse X-CUSTOM method from BRU to JSON', () => {
+ const input = fs.readFileSync(path.join(fixturesDir, 'custom-method-x-custom.bru'), 'utf8');
+ const expected = require(path.join(fixturesDir, 'custom-method-x-custom.json'));
+ const output = bruToJson(input);
+
+ expect(output).toEqual(expected);
+ });
+
+ it('should parse custom method with special characters from BRU to JSON', () => {
+ const input = fs.readFileSync(path.join(fixturesDir, 'custom-method-with-special-chars.bru'), 'utf8');
+ const expected = require(path.join(fixturesDir, 'custom-method-with-special-chars.json'));
+ const output = bruToJson(input);
+
+ expect(output).toEqual(expected);
+ });
+ });
+
+ describe('stringify (JSON to BRU)', () => {
+ it('should stringify FETCH custom method from JSON to BRU', () => {
+ const input = require(path.join(fixturesDir, 'custom-method.json'));
+ const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method.bru'), 'utf8');
+ const output = jsonToBru(input);
+
+ expect(output).toEqual(expected);
+ });
+
+ it('should stringify X-CUSTOM method from JSON to BRU', () => {
+ const input = require(path.join(fixturesDir, 'custom-method-x-custom.json'));
+ const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method-x-custom.bru'), 'utf8');
+ const output = jsonToBru(input);
+
+ expect(output).toEqual(expected);
+ });
+
+ it('should stringify custom method with special characters from JSON to BRU', () => {
+ const input = require(path.join(fixturesDir, 'custom-method-with-special-chars.json'));
+ const expected = fs.readFileSync(path.join(fixturesDir, 'custom-method-with-special-chars.bru'), 'utf8');
+ const output = jsonToBru(input);
+
+ expect(output).toEqual(expected);
+ });
+ });
+});
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.bru b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.bru
new file mode 100644
index 000000000..a8ae5129a
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.bru
@@ -0,0 +1,10 @@
+meta {
+ name: Custom Method with Special Characters
+ type: http
+ seq: 3
+}
+
+http {
+ method: CUSTOM@METHOD
+ url: https://api.example.com/special-method
+}
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.json b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.json
new file mode 100644
index 000000000..695f20661
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-with-special-chars.json
@@ -0,0 +1,11 @@
+{
+ "meta": {
+ "name": "Custom Method with Special Characters",
+ "type": "http",
+ "seq": "3"
+ },
+ "http": {
+ "method": "CUSTOM@METHOD",
+ "url": "https://api.example.com/special-method"
+ }
+}
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.bru b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.bru
new file mode 100644
index 000000000..25e36e8bf
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.bru
@@ -0,0 +1,10 @@
+meta {
+ name: Custom Method X-CUSTOM
+ type: http
+ seq: 2
+}
+
+http {
+ method: X-CUSTOM
+ url: https://api.example.com/x-custom
+}
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.json b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.json
new file mode 100644
index 000000000..cdf46428e
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method-x-custom.json
@@ -0,0 +1,11 @@
+{
+ "meta": {
+ "name": "Custom Method X-CUSTOM",
+ "type": "http",
+ "seq": "2"
+ },
+ "http": {
+ "method": "X-CUSTOM",
+ "url": "https://api.example.com/x-custom"
+ }
+}
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.bru b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.bru
new file mode 100644
index 000000000..b517e7c5e
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.bru
@@ -0,0 +1,10 @@
+meta {
+ name: Custom Method FETCH
+ type: http
+ seq: 1
+}
+
+http {
+ method: FETCH
+ url: https://api.example.com/custom
+}
diff --git a/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.json b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.json
new file mode 100644
index 000000000..6be5306c3
--- /dev/null
+++ b/packages/bruno-lang/v2/tests/custom-methods/fixtures/custom-method.json
@@ -0,0 +1,11 @@
+{
+ "meta": {
+ "name": "Custom Method FETCH",
+ "type": "http",
+ "seq": "1"
+ },
+ "http": {
+ "method": "FETCH",
+ "url": "https://api.example.com/custom"
+ }
+}
diff --git a/packages/bruno-lang/v2/tests/dictionary.spec.js b/packages/bruno-lang/v2/tests/dictionary.spec.js
index dd9ddd472..8e9197576 100644
--- a/packages/bruno-lang/v2/tests/dictionary.spec.js
+++ b/packages/bruno-lang/v2/tests/dictionary.spec.js
@@ -81,6 +81,25 @@ headers {
expect(output).toEqual(expected);
});
+ it('should parse single header with empty key', () => {
+ const input = `
+headers {
+ : world
+}`;
+
+ const output = parser(input);
+ const expected = {
+ headers: [
+ {
+ name: '',
+ value: 'world',
+ enabled: true
+ }
+ ]
+ };
+ expect(output).toEqual(expected);
+ });
+
it('should parse multi headers', () => {
const input = `
headers {
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru
index dc70da54b..c10982f6e 100644
--- a/packages/bruno-lang/v2/tests/fixtures/request.bru
+++ b/packages/bruno-lang/v2/tests/fixtures/request.bru
@@ -17,6 +17,11 @@ get {
params:query {
apiKey: secret
numbers: 998877665
+ "key with spaces": is allowed
+ "colon:parameter": is allowed
+ "nested escaped \"quote\"": is allowed
+ "{braces}": is allowed
+ ~"disabled:colon:parameter": is allowed
~message: hello
}
@@ -27,6 +32,11 @@ params:path {
headers {
content-type: application/json
Authorization: Bearer 123
+ "key with spaces": is allowed
+ "colon:header": is allowed
+ "{braces}": is allowed
+ "nested escaped \"quote\"": is allowed
+ ~"disabled:colon:header": is allowed
~transaction-id: {{transactionId}}
}
@@ -104,13 +114,23 @@ body:sparql {
body:form-urlencoded {
apikey: secret
numbers: +91998877665
+ "key with spaces": is allowed
+ "colon:parameter": is allowed
+ "nested escaped \"quote\"": is allowed
+ "{braces}": is allowed
~message: hello
+ ~"disabled colon:parameter": is allowed
}
body:multipart-form {
apikey: secret
numbers: +91998877665
+ "key with spaces": is allowed
+ "colon:part": is allowed
+ "nested escaped \"quote\"": is allowed
+ "{braces}": is allowed
~message: hello
+ ~"disabled colon:part": is allowed
}
body:file {
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json
index 539eae116..1b6e66497 100644
--- a/packages/bruno-lang/v2/tests/fixtures/request.json
+++ b/packages/bruno-lang/v2/tests/fixtures/request.json
@@ -24,6 +24,36 @@
"type": "query",
"enabled": true
},
+ {
+ "name": "key with spaces",
+ "value": "is allowed",
+ "type": "query",
+ "enabled": true
+ },
+ {
+ "name" : "colon:parameter",
+ "value" : "is allowed",
+ "type": "query",
+ "enabled": true
+ },
+ {
+ "name" : "nested escaped \"quote\"",
+ "value" : "is allowed",
+ "type": "query",
+ "enabled": true
+ },
+ {
+ "name": "{braces}",
+ "value": "is allowed",
+ "type": "query",
+ "enabled": true
+ },
+ {
+ "name" : "disabled:colon:parameter",
+ "value" : "is allowed",
+ "type": "query",
+ "enabled": false
+ },
{
"name": "message",
"value": "hello",
@@ -48,6 +78,31 @@
"value": "Bearer 123",
"enabled": true
},
+ {
+ "name": "key with spaces",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "colon:header",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "{braces}",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "nested escaped \"quote\"",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "disabled:colon:header",
+ "value": "is allowed",
+ "enabled": false
+ },
{
"name": "transaction-id",
"value": "{{transactionId}}",
@@ -118,10 +173,35 @@
"value": "+91998877665",
"enabled": true
},
+ {
+ "name": "key with spaces",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "colon:parameter",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "nested escaped \"quote\"",
+ "value": "is allowed",
+ "enabled": true
+ },
+ {
+ "name": "{braces}",
+ "value": "is allowed",
+ "enabled": true
+ },
{
"name": "message",
"value": "hello",
"enabled": false
+ },
+ {
+ "name": "disabled colon:parameter",
+ "value": "is allowed",
+ "enabled": false
}
],
"multipartForm": [
@@ -139,12 +219,47 @@
"enabled": true,
"type": "text"
},
+ {
+ "contentType": "",
+ "name": "key with spaces",
+ "value": "is allowed",
+ "enabled": true,
+ "type": "text"
+ },
+ {
+ "contentType": "",
+ "name": "colon:part",
+ "value": "is allowed",
+ "enabled": true,
+ "type": "text"
+ },
+ {
+ "contentType": "",
+ "name": "nested escaped \"quote\"",
+ "value": "is allowed",
+ "enabled": true,
+ "type": "text"
+ },
+ {
+ "contentType": "",
+ "name": "{braces}",
+ "value": "is allowed",
+ "enabled": true,
+ "type": "text"
+ },
{
"contentType": "",
"name": "message",
"value": "hello",
"enabled": false,
"type": "text"
+ },
+ {
+ "contentType": "",
+ "name": "disabled colon:part",
+ "value": "is allowed",
+ "enabled": false,
+ "type": "text"
}
],
"file" : [
diff --git a/packages/bruno-requests/src/auth/digestauth-helper.js b/packages/bruno-requests/src/auth/digestauth-helper.js
index c1cdc849a..d6b93b98f 100644
--- a/packages/bruno-requests/src/auth/digestauth-helper.js
+++ b/packages/bruno-requests/src/auth/digestauth-helper.js
@@ -89,9 +89,16 @@ export function addDigestInterceptor(axiosInstance, request) {
authDetails.algorithm = 'MD5';
}
- const uri = new URL(request.url, request.baseURL || 'http://localhost').pathname; // Handle relative URLs
+ // Build full URL from the original request (may include query params and baseURL)
+ const resolvedUrl = new URL(
+ originalRequest.url || request.url,
+ originalRequest.baseURL || request.baseURL || 'http://localhost'
+ );
+ const uri = `${resolvedUrl.pathname}${resolvedUrl.search}`;
+ // Used 'GET' as default method to avoid missing method error
+ const method = (originalRequest.method || request.method || 'GET').toUpperCase();
const HA1 = md5(`${username}:${authDetails.realm}:${password}`);
- const HA2 = md5(`${request.method}:${uri}`);
+ const HA2 = md5(`${method}:${uri}`);
const response = md5(
`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`
);
diff --git a/packages/bruno-requests/src/auth/digestauth-helper.spec.js b/packages/bruno-requests/src/auth/digestauth-helper.spec.js
new file mode 100644
index 000000000..4eb3a405a
--- /dev/null
+++ b/packages/bruno-requests/src/auth/digestauth-helper.spec.js
@@ -0,0 +1,58 @@
+const axios = require('axios');
+const { addDigestInterceptor } = require('./digestauth-helper');
+
+describe('Digest Auth with query params', () => {
+ test('uri should include path and query string', async () => {
+ const axiosInstance = axios.create();
+
+ let callCount = 0;
+ let capturedAuthorization;
+
+ // Custom adapter to simulate a 401 challenge then a 200 success
+ axiosInstance.defaults.adapter = async (config) => {
+ callCount += 1;
+ if (callCount === 1) {
+ const error = new Error('Unauthorized');
+ error.config = config;
+ error.response = {
+ status: 401,
+ headers: {
+ 'www-authenticate': 'Digest realm="test", nonce="abc", qop="auth"'
+ }
+ };
+ throw error;
+ }
+
+ // Second call should have Authorization header set by interceptor
+ capturedAuthorization = config.headers && (config.headers.Authorization || config.headers.authorization);
+ return {
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config,
+ data: { ok: true }
+ };
+ };
+
+ const request = {
+ method: 'GET',
+ url: 'http://example.com/resource?foo=bar&baz=qux',
+ headers: {},
+ digestConfig: { username: 'user', password: 'pass' }
+ };
+
+ addDigestInterceptor(axiosInstance, request);
+
+ const res = await axiosInstance(request);
+ expect(res.status).toBe(200);
+
+ expect(capturedAuthorization).toBeTruthy();
+ // Extract uri="..." from the header
+ const uriMatch = /uri="([^"]+)"/.exec(capturedAuthorization);
+ expect(uriMatch).toBeTruthy();
+ const uri = uriMatch[1];
+
+ // Expected to include both pathname and query
+ expect(uri).toBe('/resource?foo=bar&baz=qux');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index 05b04cb08..3a24808da 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', 'TRACE'])
+ .min(1, 'method is required')
.required('method is required');
const graphqlBodySchema = Yup.object({
diff --git a/packages/bruno-schema/src/collections/requestSchema.spec.js b/packages/bruno-schema/src/collections/requestSchema.spec.js
index 9fd223cb2..258b1138f 100644
--- a/packages/bruno-schema/src/collections/requestSchema.spec.js
+++ b/packages/bruno-schema/src/collections/requestSchema.spec.js
@@ -18,10 +18,10 @@ describe('Request Schema Validation', () => {
expect(isValid).toBeTruthy();
});
- it('request schema must throw an error of method is invalid', async () => {
+ it('request schema must validate successfully - custom method', async () => {
const request = {
url: 'https://restcountries.com/v2/alpha/in',
- method: 'GET-junk',
+ method: 'FOO',
headers: [],
params: [],
body: {
@@ -29,12 +29,51 @@ 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, TRACE'
- )
- )
- ]);
+ const isValid = await requestSchema.validate(request);
+ expect(isValid).toBeTruthy();
+ });
+
+ it('request schema must validate successfully - custom method with dash', async () => {
+ const request = {
+ url: 'https://restcountries.com/v2/alpha/in',
+ method: 'X-CUSTOM',
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none'
+ }
+ };
+
+ const isValid = await requestSchema.validate(request);
+ expect(isValid).toBeTruthy();
+ });
+
+ it('request schema must throw an error if method is empty', async () => {
+ const request = {
+ url: 'https://restcountries.com/v2/alpha/in',
+ method: '',
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none'
+ }
+ };
+
+ await expect(requestSchema.validate(request)).rejects.toThrow();
+ });
+
+ it('request schema must validate successfully - method with space is allowed now', async () => {
+ const request = {
+ url: 'https://restcountries.com/v2/alpha/in',
+ method: 'GET JUNK',
+ headers: [],
+ params: [],
+ body: {
+ mode: 'none'
+ }
+ };
+
+ const isValid = await requestSchema.validate(request);
+ expect(isValid).toBeTruthy();
});
});
diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/Redirect Cookie Save.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/Redirect Cookie Save.bru
new file mode 100644
index 000000000..f120acdfb
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/Redirect Cookie Save.bru
@@ -0,0 +1,32 @@
+meta {
+ name: Redirect Cookie Save
+ type: http
+ seq: 9
+}
+
+get {
+ url: https://httpbun.com/mix/s=302/c=foo:bar/r=https%3A%2F%2Fhttpbun.org%2Fget
+ body: none
+ auth: inherit
+}
+
+tests {
+ const jar = bru.cookies.jar()
+
+ const cookieData = await jar.getCookie(
+ "https://httpbun.com",
+ "foo"
+ );
+
+ test("should store redirect cookie under initial request domain", function () {
+ expect(cookieData).to.not.be.undefined;
+ expect(cookieData.key).to.equal("foo");
+ expect(cookieData.value).to.equal("bar");
+ });
+
+ jar.clear();
+}
+
+settings {
+ encodeUrl: true
+}
diff --git a/packages/bruno-tests/collection/string interpolation/runtime vars.bru b/packages/bruno-tests/collection/string interpolation/runtime vars.bru
index 3bcdef9e9..d16ad4336 100644
--- a/packages/bruno-tests/collection/string interpolation/runtime vars.bru
+++ b/packages/bruno-tests/collection/string interpolation/runtime vars.bru
@@ -38,9 +38,28 @@ assert {
}
script:pre-request {
+ const brunoBirthDate = new Date('2019-08-08');
+
+ const calculateAgeFromBirthDate = (birthDate = brunoBirthDate) => {
+ const today = new Date();
+ let age = today.getFullYear() - birthDate.getFullYear();
+
+ const hasBirthdayPassedThisYear =
+ today.getMonth() > birthDate.getMonth() ||
+ (today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
+
+ if (!hasBirthdayPassedThisYear) {
+ age--;
+ }
+
+ return age;
+ };
+
+ const brunoAge = calculateAgeFromBirthDate(brunoBirthDate);
+
bru.setVar("rUser", {
full_name: 'Bruno',
- age: 5,
+ age: brunoAge,
'fav-food': ['egg', 'meat'],
'want.attention': true
});
@@ -48,8 +67,27 @@ script:pre-request {
tests {
test("should return json", function() {
+ const brunoBirthDate = new Date('2019-08-08');
+
+ const calculateAgeFromBirthDate = (birthDate = brunoBirthDate) => {
+ const today = new Date();
+ let age = today.getFullYear() - birthDate.getFullYear();
+
+ const hasBirthdayPassedThisYear =
+ today.getMonth() > birthDate.getMonth() ||
+ (today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate());
+
+ if (!hasBirthdayPassedThisYear) {
+ age--;
+ }
+
+ return age;
+ };
+
+ const brunoAge = calculateAgeFromBirthDate(brunoBirthDate);
+
const expectedResponse = `Hi, I am Bruno,
- I am 5 years old.
+ I am ${brunoAge} 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_level_oauth2/package-lock.json b/packages/bruno-tests/collection_level_oauth2/package-lock.json
index 717181ec3..cd3ca8310 100644
--- a/packages/bruno-tests/collection_level_oauth2/package-lock.json
+++ b/packages/bruno-tests/collection_level_oauth2/package-lock.json
@@ -8,19 +8,20 @@
"name": "@usebruno/test-collection",
"version": "0.0.1",
"dependencies": {
- "@faker-js/faker": "^8.4.0"
+ "@faker-js/faker": "^8.4.1"
}
},
"node_modules/@faker-js/faker": {
- "version": "8.4.0",
- "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz",
- "integrity": "sha512-htW87352wzUCdX1jyUQocUcmAaFqcR/w082EC8iP/gtkF0K+aKcBp0hR5Arb7dzR8tQ1TrhE9DNa5EbJELm84w==",
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
+ "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
+ "license": "MIT",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
"npm": ">=6.14.13"
diff --git a/packages/bruno-tests/collection_level_oauth2/package.json b/packages/bruno-tests/collection_level_oauth2/package.json
index 23621129b..f4bc84e87 100644
--- a/packages/bruno-tests/collection_level_oauth2/package.json
+++ b/packages/bruno-tests/collection_level_oauth2/package.json
@@ -2,6 +2,6 @@
"name": "@usebruno/test-collection",
"version": "0.0.1",
"dependencies": {
- "@faker-js/faker": "^8.4.0"
+ "@faker-js/faker": "^8.4.1"
}
}
diff --git a/readme.md b/readme.md
index ed37afc1f..3c800536a 100644
--- a/readme.md
+++ b/readme.md
@@ -94,9 +94,9 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
-sudo apt update && sudo apt install gpg
+sudo apt update && sudo apt install gpg curl
sudo gpg --list-keys
-sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
+curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" | gpg --dearmor | sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```