Merge branch 'main' into feat/websocket-engine

This commit is contained in:
Siddharth Gelera
2025-09-02 23:45:06 +05:30
75 changed files with 3074 additions and 969 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 813 KiB

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar ${className}`}
{...rest}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M9 4l0 16" />
{!collapsed && (
<rect x="4.6" y="4.6" width="4.8" height="14.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconSidebarToggle;

View File

@@ -79,7 +79,7 @@ const Notifications = () => {
const modalCustomHeader = (
<div className="flex flex-row gap-8">
<div>NOTIFICATIONS</div>
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">

View File

@@ -84,7 +84,7 @@ const Beta = ({ close }) => {
<h2 className="text-lg font-semibold">Beta Features</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-wrap">
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.
</p>
</div>
@@ -103,6 +103,16 @@ const Beta = ({ close }) => {
<label className="block ml-2 select-none font-medium" htmlFor={feature.id}>
{feature.label}
</label>
{feature.id === 'grpc' && (
<a
href="https://github.com/usebruno/bruno/discussions/5447"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-xs text-blue-500 hover:text-blue-600 underline"
>
Share feedback
</a>
)}
</div>
<div className="beta-feature-description ml-6 text-xs text-gray-500 dark:text-gray-400">
{feature.description}

View File

@@ -30,6 +30,10 @@ const GrpcAuthMode = ({ item, collection }) => {
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'WSSE Auth',
mode: 'wsse'
},
{
name: 'Inherit',
mode: 'inherit'

View File

@@ -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 <OAuth2 collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@@ -147,7 +147,7 @@ const QueryParams = ({ item, collection }) => {
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '31%' },
{ name: 'Path', accessor: 'path', width: '56%' },
{ name: 'Value', accessor: 'path', width: '56%' },
{ name: '', accessor: '', width: '13%' }
]}
>

View File

@@ -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);

View File

@@ -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 (
<div className="dropdown-item" onClick={() => onSelect(verb)}>
{verb}
</div>
);
}
const Icon = forwardRef(function IconComponent(
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
ref
) {
if (isCustomMode) {
return (
<div ref={ref} className="flex w-full items-center pl-3 py-1 select-none uppercase">
<div className="flex-grow font-medium" id="create-new-request-method">
{method}
</div>
<div>
<IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
</div>
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="font-medium px-2 w-full focus:bg-transparent"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={inputValue}
autoFocus
/>
</div>
);
});
}
const handleMethodSelect = (verb) => onMethodSelect(verb);
const Verb = ({ verb }) => {
return (
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleMethodSelect(verb);
}}
return (
<div ref={ref} className="flex pr-4 select-none">
<button
type="button"
className="cursor-pointer flex items-center text-left w-full"
>
{verb}
</div>
);
<span
className="font-medium px-2 truncate method-span"
id="create-new-request-method"
title={inputValue}
>
{inputValue}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
</div>
);
});
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 (
<StyledWrapper>
<div className="flex items-center cursor-pointer method-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-start">
<Verb verb="GET" />
<Verb verb="POST" />
<Verb verb="PUT" />
<Verb verb="DELETE" />
<Verb verb="PATCH" />
<Verb verb="OPTIONS" />
<Verb verb="HEAD" />
<div className="flex method-selector">
<Dropdown
onCreate={onDropdownCreate}
icon={
<Icon
isCustomMode={isCustomMode}
inputValue={method}
handleInputChange={handleInputChange}
handleBlur={handleBlur}
handleKeyDown={handleKeyDown}
inputRef={inputRef}
/>
}
placement="bottom-start"
>
<div>
{STANDARD_METHODS.map((verb) => (
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
))}
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
<span className="text-link">+ Add Custom</span>
</div>
</div>
</Dropdown>
</div>
</StyledWrapper>

View File

@@ -80,7 +80,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper className="flex items-center">
<div className="flex items-center h-full method-selector-container">
<div className="flex flex-1 items-center h-full method-selector-container">
{isGrpc ? (
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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))));

View File

@@ -501,7 +501,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
</label>
<div className="flex items-center mt-2 ">
{!['grpc-request', 'ws-request'].includes(formik.values.requestType) ? (
<div className="flex items-center h-full method-selector-container">
<div className="flex items-center h-full method-selector-container w-1/5">
<HttpMethodSelector
method={formik.values.requestMethod}
onMethodSelect={(val) => formik.setFieldValue('requestMethod', val)}

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className="flex relative h-full">
<aside>
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
<div className="flex flex-row h-full w-full">
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
<div className="flex flex-col flex-grow">
@@ -84,9 +88,11 @@ const Sidebar = () => {
</div>
</aside>
<div className="absolute drag-sidebar h-full" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
</div>
{!sidebarCollapsed && (
<div className="absolute sidebar-drag-handle h-full" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
</div>
)}
</StyledWrapper>
);
};

View File

@@ -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 = () => {
<div className="status-bar">
<div className="status-bar-section">
<div className="status-bar-group">
<ToolHint text="Toggle Sidebar" toolhintId="Toggle Sidebar" place="top-start" offset={10}>
<button
className="status-bar-button"
aria-label="Toggle Sidebar"
onClick={() => dispatch(toggleSidebarCollapse())}
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</ToolHint>
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
<button
className="status-bar-button"

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
import Bruno from 'components/Bruno';
@@ -14,13 +15,19 @@ import StyledWrapper from './StyledWrapper';
const Welcome = () => {
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 }) => {

View File

@@ -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 (

View File

@@ -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' }
};
/**

View File

@@ -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) => {

View File

@@ -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
// arent 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);
});
});

View File

@@ -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;
}

View File

@@ -131,6 +131,10 @@
--px-12: 2px !important;
}
.CodeMirror-hints {
z-index: 20 !important;
}
.graphiql-container {
background: transparent !important;
}

View File

@@ -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: {}

View File

@@ -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);
};

View File

@@ -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))

View File

@@ -1,637 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Would use latest version, you'd better specify a version -->
<script src="https://unpkg.com/naive-ui"></script>
<title>Bruno</title>
<style>
.error > .status {
color: red;
}
.success > .status {
color: green;
}
.n-collapse-item.success > .n-collapse-item__header {
background-color: rgba(237, 247, 242, 1);
}
.n-collapse-item.error > .n-collapse-item__header {
background-color: rgba(251, 238, 241, 1);
}
.min-width-150 {
min-width: 150px;
}
</style>
</head>
<body>
<div id="app">
<n-config-provider :theme="theme">
<n-layout embedded position="absolute" content-style="padding: 24px;">
<n-card>
<n-flex>
<n-page-header title="Bruno run dashboard">
<template #avatar>
<n-avatar size="large" style="background-color: transparent">
<svg id="emoji" width="34" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="color">
<path
fill="#F4AA41"
stroke="none"
d="M23.5,14.5855l-4.5,1.75l-7.25,8.5l-4.5,10.75l2,5.25c1.2554,3.7911,3.5231,7.1832,7.25,10l2.5-3.3333 c0,0,3.8218,7.7098,10.7384,8.9598c0,0,10.2616,1.936,15.5949-0.8765c3.4203-1.8037,4.4167-4.4167,4.4167-4.4167l3.4167-3.4167 l1.5833,2.3333l2.0833-0.0833l5.4167-7.25L64,37.3355l-0.1667-4.5l-2.3333-5.5l-4.8333-7.4167c0,0-2.6667-4.9167-8.1667-3.9167 c0,0-6.5-4.8333-11.8333-4.0833S32.0833,10.6688,23.5,14.5855z"
></path>
<polygon
fill="#EA5A47"
stroke="none"
points="36,47.2521 32.9167,49.6688 30.4167,49.6688 30.3333,53.5021 31.0833,57.0021 32.1667,58.9188 35,60.4188 39.5833,59.8355 41.1667,58.0855 42.1667,53.8355 41.9167,49.8355 39.9167,50.0855"
></polygon>
<polygon
fill="#3F3F3F"
stroke="none"
points="32.5,36.9188 30.9167,40.6688 33.0833,41.9188 34.3333,42.4188 38.6667,42.5855 41.5833,40.3355 39.8333,37.0855"
></polygon>
</g>
<g id="hair"></g>
<g id="skin"></g>
<g id="skin-shadow"></g>
<g id="line">
<path
fill="#000000"
stroke="none"
d="M29.5059,30.1088c0,0-1.8051,1.2424-2.7484,0.6679c-0.9434-0.5745-1.2424-1.8051-0.6679-2.7484 s1.805-1.2424,2.7484-0.6679S29.5059,30.1088,29.5059,30.1088z"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M33.1089,37.006h6.1457c0.4011,0,0.7634,0.2397,0.9203,0.6089l1.1579,2.7245l-2.1792,1.1456 c-0.6156,0.3236-1.3654-0.0645-1.4567-0.754"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M34.7606,40.763c-0.1132,0.6268-0.7757,0.9895-1.3647,0.7471l-2.3132-0.952l1.0899-2.9035 c0.1465-0.3901,0.5195-0.6486,0.9362-0.6486"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M30.4364,50.0268c0,0-0.7187,8.7934,3.0072,9.9375c2.6459,0.8125,5.1497,0.5324,6.0625-0.25 c0.875-0.75,2.6323-4.4741,1.8267-9.6875"
></path>
<path
fill="#000000"
stroke="none"
d="M44.2636,30.1088c0,0,1.805,1.2424,2.7484,0.6679c0.9434-0.5745,1.2424-1.8051,0.6679-2.7484 c-0.5745-0.9434-1.805-1.2424-2.7484-0.6679C43.9881,27.9349,44.2636,30.1088,44.2636,30.1088z"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M25.6245,42.8393c-0.475,3.6024,2.2343,5.7505,4.2847,6.8414c1.1968,0.6367,2.6508,0.5182,3.7176-0.3181l2.581-2.0233l2.581,2.0233 c1.0669,0.8363,2.5209,0.9548,3.7176,0.3181c2.0504-1.0909,4.7597-3.239,4.2847-6.8414"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M19.9509,28.3572c-2.3166,5.1597-0.5084,13.0249,0.119,15.3759c0.122,0.4571,0.0755,0.9355-0.1271,1.3631l-1.9874,4.1937 c-0.623,1.3146-2.3934,1.5533-3.331,0.4409c-3.1921-3.7871-8.5584-11.3899-6.5486-16.686 c7.0625-18.6104,15.8677-18.1429,15.8677-18.1429c2.8453-1.9336,13.1042-6.9375,24.8125,0.875c0,0,8.6323-1.7175,14.9375,16.9375 c1.8036,5.3362-3.4297,12.8668-6.5506,16.6442c-0.9312,1.127-2.7162,0.8939-3.3423-0.4272l-1.9741-4.1656 c-0.2026-0.4275-0.2491-0.906-0.1271-1.3631c0.6275-2.3509,2.4356-10.2161,0.119-15.3759"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M52.6309,46.4628c0,0-3.0781,6.7216-7.8049,8.2712"
></path>
<path
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
d="M19.437,46.969c0,0,3.0781,6.0823,7.8049,7.632"
></path>
<line
x1="36.2078"
x2="36.2078"
y1="47.3393"
y2="44.3093"
fill="none"
stroke="#000000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
stroke-width="2"
></line>
</g>
</svg>
</n-avatar>
</template>
<template #extra>
<n-flex justify="end">
<n-switch v-model:value="darkMode" :rail-style="darkModeRailStyle">
<template #checked> Dark </template>
<template #unchecked> Light </template>
</n-switch>
</n-flex>
</template>
</n-page-header>
<n-tabs type="segment" animated>
<n-tab-pane name="summary" tab="Summary">
<x-summary :res="res"></x-summary>
</n-tab-pane>
<n-tab-pane name="Requests" tab="Requests">
<x-requests :res="res"></x-requests>
</n-tab-pane>
</n-tabs>
</n-flex>
</n-card>
</n-layout>
</n-config-provider>
</div>
<script type="text/x-template" id="summary-component">
<n-flex vertical>
<n-flex justify="center">
<n-alert type="success">
<n-statistic
label="Total Controls"
:value="summaryTotalControls"
>
</n-statistic>
</n-alert>
<n-alert :type="summaryFailedControls ? 'error' : 'success'">
<n-statistic
label="Total Failed Controls"
:value="summaryFailedControls"
>
</n-statistic>
</n-alert>
<n-alert :type="summaryErrors ? 'error' : 'success'">
<n-statistic label="Total errors" :value="summaryErrors">
</n-statistic>
</n-alert>
</n-flex>
<n-card title="TIMINGS AND DATA">
<n-flex justify="center">
<n-statistic
label="Total run duration"
:value="Math.round(totalRunDuration*1000)/1000"
>
<template #suffix>s</template>
</n-statistic>
<n-statistic
label="Total requests"
:value="summaryTotalRequests"
>
</n-statistic>
</n-flex>
</n-card>
<n-data-table :columns="summaryColumns" :data="summaryData" />
</n-flex>
</script>
<script type="text/x-template" id="requests-component">
<n-flex vertical>
<n-switch
v-model:value="onlyFailed"
:rail-style="railStyle"
>
<template #checked> Only Failed </template>
<template #unchecked> Only Failed </template>
</n-switch>
<n-collapse>
<x-results-group v-for="(results, group) in groupedResults" :results="results" :group="group" :key="group + '-' + results.length"></x-results-group>
</n-collapse>
</n-flex>
</script>
<script type="text/x-template" id="results-group-component">
<n-collapse-item
:name="group"
arrow-placement="right"
>
<template #header>
<n-alert
:type="hasError || hasFailure ? 'error' : 'success'"
:bordered="false"
>
<template #header>
{{group}} - {{totalPassed}} / {{total}} Passed {{ hasError? " - Error" : "" }}
</template>
</n-alert>
</template>
<n-collapse>
<x-result v-for="(result, index) in results" :result="result" :group="group" :key="index"></x-result>
</n-collapse>
</n-collapse-item>
</script>
<script type="text/x-template" id="result-component">
<n-collapse-item
:name="name"
arrow-placement="right"
>
<template #header>
<n-alert
:type="hasError || hasFailure ? 'error' : 'success'"
:bordered="false"
>
<template #header>
{{suitename}} - {{totalPassed}} / {{total}} Passed {{hasError ? " - Error" : "" }}
</template>
</n-alert>
</template>
<n-flex vertical>
<n-grid x-gap="12" :cols="2">
<n-gi>
<n-card title="REQUEST INFORMATION">
<n-list>
<n-list-item>
<n-thing
title="File"
:description="result.test.filename"
/>
</n-list-item>
<n-list-item>
<n-thing
title="Request Method"
:description="result.request.method"
/>
</n-list-item>
<n-list-item>
<n-thing
title="Request URL"
:description="result.request.url"
/>
</n-list-item>
</n-list>
</n-card>
</n-gi>
<n-gi>
<n-card title="RESPONSE INFORMATION">
<n-list>
<n-list-item>
<n-thing
title="Response Code"
:description="'' + result.response.status"
/>
</n-list-item>
<n-list-item>
<n-thing
title="Response time"
:description="result.response.responseTime + ' ms'"
/>
</n-list-item>
<n-list-item>
<n-thing
title="Test duration"
:description="testDuration"
/>
</n-list-item>
</n-list>
</n-card>
</n-gi>
</n-grid>
<n-alert v-if="hasError" title="Error" type="error">
{{result.error}}
</n-alert>
<n-card title="REQUEST HEADERS">
<n-data-table
:columns="headerColumns"
:data="headerDataRequest"
/>
</n-card>
<n-card
v-if="result.request.data"
title="REQUEST BODY"
>
<pre>{{result.request.data}}</pre>
</n-card>
<n-card title="RESPONSE HEADERS">
<n-data-table
:columns="headerColumns"
:data="headerDataResponse"
/>
</n-card>
<n-card
v-if="result.response.data"
title="RESPONSE BODY"
>
<pre>{{result.response.data}}</pre>
</n-card>
<n-card title="ASSERTIONS INFORMATION">
<n-data-table
:columns="assertionsColumns"
:data="result.assertionResults"
:row-class-name="assertionsRowClassName"
/>
</n-card>
<n-card title="TESTS INFORMATION">
<n-data-table
:columns="testsColumns"
:data="result.testResults"
:row-class-name="testsRowClassName"
/>
</n-card>
</n-flex>
</n-collapse-item>
</script>
<script>
const { createApp, ref, computed } = Vue;
const App = {
setup() {
const res = __RESULTS_JSON__;
const darkMode = ref(false);
const theme = computed(() => {
return darkMode.value ? naive.darkTheme : null;
});
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
darkMode.value = true;
}
// To watch for os theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
darkMode.value = event.matches;
});
return {
res,
theme,
darkMode,
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' })
};
}
};
const app = Vue.createApp(App);
app.component('x-summary', {
template: `#summary-component`,
props: ['res'],
setup(props) {
const summaryColumns = [
{
title: 'SUMMARY ITEM',
key: 'title'
},
{
title: 'TOTAL',
key: 'total'
},
{
title: 'PASSED',
key: 'passed'
},
{
title: 'FAILED',
key: 'failed'
}
];
const summaryData = computed(() => [
{
title: 'Requests',
total: props.res.summary.totalRequests,
passed: props.res.summary.passedRequests,
failed: props.res.summary.failedRequests
},
{
title: 'Assertions',
total: props.res.summary.totalAssertions,
passed: props.res.summary.passedAssertions,
failed: props.res.summary.failedAssertions
},
{
title: 'Tests',
total: props.res.summary.totalTests,
passed: props.res.summary.passedTests,
failed: props.res.summary.failedTests
}
]);
const summaryTotalRequests = computed(() => {
return props.res.summary.totalRequests;
});
const summaryTotalControls = computed(() => {
return props.res.summary.totalTests + props.res.summary.totalAssertions;
});
const summaryFailedControls = computed(
() => props.res.summary.failedRequests + props.res.summary.failedTests + props.res.summary.failedAssertions
);
const summaryErrors = computed(() => props.res.results.filter((r) => r.error).length);
const totalRunDuration = computed(() => props.res?.results?.reduce((total, test) => test.runtime + total, 0));
return {
summaryColumns,
summaryData,
summaryTotalControls,
summaryTotalRequests,
summaryFailedControls,
summaryErrors,
totalRunDuration
};
}
});
app.component('x-requests', {
template: `#requests-component`,
props: ['res'],
setup(props) {
const onlyFailed = ref(false);
const filteredResults = computed(() => {
if (onlyFailed.value) {
return props.res.results.filter(
(r) =>
!!r.error ||
!!r.testResults.find((t) => t.status !== 'pass') ||
!!r.assertionResults.find((t) => t.status !== 'pass')
);
}
return props.res.results;
});
const groupedResults = computed(() => {
return filteredResults.value.reduce((groups, curr) => {
const path = curr.suitename.split('/');
const test = path.pop();
const name = path.length ? path.join('/') : '(root)';
if (!groups[name]) {
groups[name] = [];
}
groups[name].push(curr);
return groups;
}, {});
});
return {
onlyFailed,
groupedResults,
railStyle: ({ checked }) => {
const style = {};
if (checked) {
style.background = '#d03050';
}
return style;
}
};
}
});
app.component('x-results-group', {
template: `#results-group-component`,
props: ['group', 'results'],
setup(props) {
const totalPassed = computed(() => {
return props.results.reduce((total, curr) => {
return (
total +
curr.testResults.filter((t) => t.status === 'pass').length +
curr.assertionResults.filter((t) => t.status === 'pass').length
);
}, 0);
});
const total = computed(() => {
return props.results.reduce((total, curr) => {
return total + curr.testResults.length + curr.assertionResults.length;
}, 0);
});
const hasError = computed(() => props.results.some((r) => !!r.error));
const hasFailure = computed(() => totalPassed.value !== total.value);
return {
totalPassed,
total,
hasFailure,
hasError,
group: props.group,
results: props.results
};
}
});
app.component('x-result', {
template: `#result-component`,
props: ['group', 'result'],
setup(props) {
const headerColumns = [
{
title: 'Header Name',
key: 'name',
className: 'min-width-150'
},
{
title: 'Header Value',
key: 'value'
}
];
const assertionsColumns = [
{
title: 'Expression',
key: 'lhsExpr'
},
{
title: 'Operator',
key: 'operator'
},
{
title: 'Operand',
key: 'rhsOperand'
},
{
title: 'Status',
key: 'status',
className: 'status'
},
{
title: 'Error',
key: 'error'
}
];
const assertionsRowClassName = (row) => {
return row.status === 'fail' ? 'error' : 'success';
};
const testsRowClassName = (row) => {
return row.status === 'fail' ? 'error' : 'success';
};
const testsColumns = [
{
title: 'Description',
key: 'description'
},
{
title: 'Status',
key: 'status',
className: 'status'
},
{
title: 'Error',
key: 'error'
}
];
function mapHeaderToTableData(headers) {
if (!headers) {
return [];
}
return Object.keys(headers).map((name) => ({
name,
value: headers[name]
}));
}
const headerDataRequest = computed(() => {
return mapHeaderToTableData(props.result.request.headers);
});
const headerDataResponse = computed(() => {
return mapHeaderToTableData(props.result.response.headers);
});
const totalPassed = computed(() => {
return (
props.result.testResults.filter((t) => t.status === 'pass').length +
props.result.assertionResults.filter((t) => t.status === 'pass').length
);
});
const total = computed(() => {
return props.result.testResults.length + props.result.assertionResults.length;
});
const hasError = computed(() => !!props.result.error);
const hasFailure = computed(() => total.value !== totalPassed.value);
const suitename = computed(() => props.result.suitename.replace(props.group + '/', ''));
const testDuration = computed(() => Math.round(props.result.runtime * 1000) + ' ms');
const name = computed(() => props.result.suitename + props.result.runtime);
return {
headerColumns,
headerDataRequest,
headerDataResponse,
assertionsColumns,
assertionsRowClassName,
testsRowClassName,
totalPassed,
total,
hasFailure,
hasError,
testsColumns,
result: props.result,
suitename,
testDuration,
name
};
}
});
app.use(naive);
app.mount('#app');
</script>
</body>
</html>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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', []),

View File

@@ -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 }}');
});
});

View File

@@ -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';
export * as utils from './utils';

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -28,6 +28,37 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
.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;
}
</style>
</head>
<body>
@@ -162,6 +193,35 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
<n-tabs type="segment" animated v-model:value="currentTab">
<n-tab-pane name="summary" tab="Summary">
<n-flex justify="center" vertical>
<!-- Run Information Card using Naive UI components -->
<n-card title="Run Information" size="small">
<div class="metadata-grid">
<n-card class="metadata-item" size="small">
<div class="metadata-label">Date & Time</div>
<div class="metadata-value">{{ runCompletionTime }}</div>
</n-card>
<n-card class="metadata-item" size="small">
<div class="metadata-label">Version</div>
<div class="metadata-value">{{ brunoVersion }}</div>
</n-card>
<n-card class="metadata-item" size="small">
<div class="metadata-label">Environment</div>
<div class="metadata-value">{{ environment }}</div>
</n-card>
<n-card class="metadata-item" size="small">
<div class="metadata-label">Total run duration</div>
<div class="metadata-value">{{ totalDuration }}</div>
</n-card>
<n-card class="metadata-item" size="small">
<div class="metadata-label">Total data received</div>
<div class="metadata-value">{{ totalDataReceived }}</div>
</n-card>
<n-card class="metadata-item" size="small">
<div class="metadata-label">Average response time</div>
<div class="metadata-value">{{ averageResponseTime }}</div>
</n-card>
</div>
</n-card>
<x-summary v-for="(result, index) in res" :res="result" :key="index"></x-summary>
</n-flex>
</n-tab-pane>
@@ -213,12 +273,6 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
<n-statistic label="Skipped requests" :value="summarySkippedRequests">
</n-statistic>
</n-alert>
<n-statistic
label="Total run duration"
:value="Math.round(totalRunDuration*1000)/1000"
>
<template #suffix>s</template>
</n-statistic>
</n-flex>
</n-flex>
</n-card>
@@ -400,10 +454,25 @@ export const htmlTemplateString = (resutsJsonString: string) =>`<!DOCTYPE html>
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) =>`<!DOCTYPE html>
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) =>`<!DOCTYPE html>
theme,
darkMode,
darkModeRailStyle: () => ({ background: 'var(--n-rail-color)' }),
currentTab
currentTab,
brunoVersion,
environment,
totalDuration,
totalDataReceived,
averageResponseTime,
runCompletionTime
};
}
};

View File

@@ -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);
```
```

View File

@@ -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)) {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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
});
}

View File

@@ -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'),

View File

@@ -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');
});
});
});

View File

@@ -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')

View File

@@ -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];

View File

@@ -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/);
});
});

View File

@@ -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')

View File

@@ -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')

View File

@@ -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);
});
});
});

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,10 @@
meta {
name: Custom Method FETCH
type: http
seq: 1
}
http {
method: FETCH
url: https://api.example.com/custom
}

View File

@@ -0,0 +1,11 @@
{
"meta": {
"name": "Custom Method FETCH",
"type": "http",
"seq": "1"
},
"http": {
"method": "FETCH",
"url": "https://api.example.com/custom"
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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" : [

View File

@@ -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}`
);

View File

@@ -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');
});
});

View File

@@ -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({

View File

@@ -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();
});
});

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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"

View File

@@ -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"
}
}

View File

@@ -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
```