Compare commits

..

7 Commits

Author SHA1 Message Date
lohit
518b1ed441 include oauth2 additional parameters in bruno collection exports (#5422) 2025-08-28 20:10:27 +05:30
Sanjai Kumar
c1aa682c03 fix: environment persistence and UI (#5404) 2025-08-28 20:10:19 +05:30
Martin Braconi
01275acc89 Update: readme.md installation instructions via Apt (#5411) 2025-08-28 20:10:11 +05:30
naman-bruno
c8f223a000 fix: large response 2025-08-28 20:09:52 +05:30
lohit
4202b48edd chore: eslint updates and fixes (#5402) 2025-08-28 20:09:25 +05:30
lohit
69891c0bc7 electron builder updates (#5425) 2025-08-28 20:09:16 +05:30
Bijin A B
76729519c6 Update contributing.md (#5407) 2025-08-28 20:09:08 +05:30
50 changed files with 365 additions and 1655 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -1,25 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -186,8 +186,6 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.on('scroll', this.onScroll);
editor.scrollTo(null, this.props.initialScroll);
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -232,18 +230,12 @@ export default class CodeEditor extends React.Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.initialScroll !== prevProps.initialScroll) {
this.editor.scrollTo(null, this.props.initialScroll);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
this.editor = null;
}
@@ -279,8 +271,6 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', 'brunovariables');
};
onScroll = (event) => this.props.onScroll?.(event);
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);

View File

@@ -67,10 +67,10 @@ const RequestTab = ({ request, response }) => {
)}
</div>
{request?.data && (
{request?.body && (
<div className="section">
<h4>Request Body</h4>
<pre className="code-block">{formatBody(request.data)}</pre>
<pre className="code-block">{formatBody(request.body)}</pre>
</div>
)}
</div>
@@ -239,4 +239,4 @@ const RequestDetailsPanel = () => {
);
};
export default RequestDetailsPanel;
export default RequestDetailsPanel;

View File

@@ -1,28 +0,0 @@
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 className="bruno-modal-header-title">NOTIFICATIONS</div>
<div>NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">

View File

@@ -79,7 +79,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">
Beta features are experimental previews that may change before full release. Try them and share feedback.
Enable beta features, these features may be unstable or incomplete.
</p>
</div>
@@ -98,16 +98,6 @@ 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

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

View File

@@ -18,7 +18,6 @@ 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) => {
@@ -50,8 +49,7 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const maxTablistWidth = screenWidth - leftSidebarWidth - 150;
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
const showChevrons = maxTablistWidth < tabsWidth;

View File

@@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
@@ -53,10 +51,6 @@ const QueryResultPreview = ({
displayedTheme
}) => {
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const dispatch = useDispatch();
const [numPages, setNumPages] = useState(null);
@@ -72,19 +66,9 @@ const QueryResultPreview = ({
if (disableRunEventListener) {
return;
}
dispatch(sendRequest(item, collection.uid));
};
const onScroll = (event) => {
dispatch(
updateResponsePaneScrollPosition({
uid: focusedTab.uid,
scrollY: event.doc.scrollTop
})
);
};
switch (previewTab?.mode) {
case 'preview-web': {
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
@@ -127,10 +111,8 @@ const QueryResultPreview = ({
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onScroll={onScroll}
value={formattedData}
mode={mode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);

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' && newItem.status !== 'running') {
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
newItem.testStatus = getTestStatus(newItem.testResults);
newItem.assertionStatus = getTestStatus(newItem.assertionResults);
newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);

View File

@@ -30,9 +30,8 @@ export const CollectionItemDragPreview = () => {
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
if (!item.type) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = item.type === 'folder';
const shouldShowFolderIcon = !item.type || item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>

View File

@@ -13,7 +13,6 @@ 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();
@@ -46,7 +45,6 @@ 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

@@ -1,204 +1,126 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconLoader2, IconFileImport } from '@tabler/icons';
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import jsyaml from 'js-yaml';
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
const convertFileToObject = async (file) => {
const text = await file.text();
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const [isLoading, setIsLoading] = useState(false)
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'))
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let collection;
if (isPostmanCollection(data)) {
collection = await postmanToBruno(data);
}
else if (isInsomniaCollection(data)) {
collection = convertInsomniaToBruno(data);
}
else if (isOpenApiSpec(data)) {
collection = convertOpenapiToBruno(data);
}
else {
collection = await processBrunoCollection(data);
}
handleSubmit({ collection });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
};
const handleBrowseFiles = () => {
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files[0]) {
await processFile(e.target.files[0]);
}
};
if (isLoading) {
return <FullscreenLoader isLoading={isLoading} />;
const handleImportPostmanCollection = () => {
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
}
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'application/json',
'application/yaml',
'application/x-yaml'
]
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Drop file to import or{' '}
<button
className="text-blue-500 underline cursor-pointer"
onClick={handleBrowseFiles}
>
choose a file
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports Bruno, Postman, Insomnia, and OpenAPI v3 formats
</p>
</div>
</div>
const handleImportOpenapiCollection = () => {
importOpenapiCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-1 text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const FullscreenLoader = () => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
// Cycle through loading messages for better UX
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
</Modal>
);
};
return (
<>
{isLoading && <FullscreenLoader />}
{!isLoading && (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
</div>
</div>
</Modal>
)}
</>
);
};

View File

@@ -5,7 +5,6 @@ const Wrapper = styled.div`
aside {
background-color: ${(props) => props.theme.sidebar.bg};
overflow: hidden;
.collection-title {
line-height: 1.5;
@@ -42,7 +41,7 @@ const Wrapper = styled.div`
}
}
div.sidebar-drag-handle {
div.drag-sidebar {
display: flex;
align-items: center;
justify-content: center;
@@ -51,7 +50,6 @@ 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,37 +1,36 @@
import TitleBar from './TitleBar';
import Collections from './Collections';
import StyledWrapper from './StyledWrapper';
import { useApp } from 'providers/App';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } 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 sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const { version } = useApp();
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const lastWidthRef = useRef(leftSidebarWidth);
const { storedTheme } = useTheme();
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 || 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);
if (dragging) {
e.preventDefault();
let width = e.clientX + 2;
if (width < MIN_LEFT_SIDEBAR_WIDTH || width > MAX_LEFT_SIDEBAR_WIDTH) {
return;
}
setAsideWidth(width);
}
};
const handleMouseUp = (e) => {
if (dragging) {
e.preventDefault();
@@ -50,9 +49,6 @@ const Sidebar = () => {
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
if (sidebarCollapsed) {
return;
}
setDragging(true);
dispatch(
updateIsDragging({
@@ -77,7 +73,7 @@ const Sidebar = () => {
return (
<StyledWrapper className="flex relative h-full">
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
<aside>
<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">
@@ -88,11 +84,9 @@ const Sidebar = () => {
</div>
</aside>
{!sidebarCollapsed && (
<div className="absolute sidebar-drag-handle h-full" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
</div>
)}
<div className="absolute drag-sidebar h-full" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" />
</div>
</StyledWrapper>
);
};

View File

@@ -1,13 +1,12 @@
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, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { showPreferences } from 'providers/ReduxStore/slices/app';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -16,7 +15,6 @@ 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();
@@ -61,16 +59,6 @@ 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,9 +1,8 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
import Bruno from 'components/Bruno';
@@ -15,19 +14,13 @@ 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.error(err);
toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'));
});
dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
};
const handleImportCollection = ({ collection }) => {

View File

@@ -14,7 +14,6 @@ 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();
@@ -225,18 +224,6 @@ 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,8 +20,7 @@ const KeyMapping = {
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }
};
/**

View File

@@ -5,7 +5,6 @@ const initialState = {
isDragging: false,
idbConnectionReady: false,
leftSidebarWidth: 222,
sidebarCollapsed: false,
screenWidth: 500,
showHomePage: false,
showPreferences: false,
@@ -90,9 +89,6 @@ export const appSlice = createSlice({
...state.generateCode,
...action.payload
};
},
toggleSidebarCollapse: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
}
}
});
@@ -112,8 +108,7 @@ export const {
removeTaskFromQueue,
removeAllTasksFromQueue,
updateSystemProxyEnvVariables,
updateGenerateCode,
toggleSidebarCollapse
updateGenerateCode
} = 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, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import {
findCollectionByUid,
@@ -46,7 +46,7 @@ import {
} from './index';
import { each } from 'lodash';
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
@@ -260,13 +260,6 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const requestUid = uuid();
itemCopy.requestUid = requestUid;
await dispatch(
updateResponsePaneScrollPosition({
uid: state.tabs.activeTabUid,
scrollY: 0
})
);
await dispatch(
initRunRequestEvent({
requestUid,
@@ -1462,14 +1455,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
collectionSchema
.validate(collection)
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
.then(() => {
// Expand sidebar if it's collapsed after collection is successfully opened
const state = getState();
if (state.app.sidebarCollapsed) {
dispatch(toggleSidebarCollapse());
}
resolve();
})
.then(resolve)
.catch(reject);
});
});
@@ -1669,33 +1655,27 @@ export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
};
// todo: could be removed
export const loadRequestViaWorker =
({ collectionUid, pathname }) =>
(dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
// todo: could be removed
export const loadRequest =
({ collectionUid, pathname }) =>
(dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadLargeRequest =
({ collectionUid, pathname }) =>
(dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadLargeRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const mountCollection =
({ collectionUid, collectionPathname, brunoConfig }) =>
@@ -1719,17 +1699,16 @@ export const showInFolder = (collectionPath) => () => {
});
};
export const updateRunnerConfiguration =
(collectionUid, selectedRequestItems, requestItemsOrder, delay) => (dispatch) => {
dispatch(
_updateRunnerConfiguration({
collectionUid,
selectedRequestItems,
requestItemsOrder,
delay
})
);
};
export const updateRunnerConfiguration = (collectionUid, selectedRequestItems, requestItemsOrder, delay) => (dispatch) => {
dispatch(
_updateRunnerConfiguration({
collectionUid,
selectedRequestItems,
requestItemsOrder,
delay
})
);
};
export const updateActiveConnectionsInStore = (activeConnectionIds) => (dispatch, getState) => {
dispatch(updateActiveConnections(activeConnectionIds));

View File

@@ -62,10 +62,10 @@ export const tabsSlice = createSlice({
? preview
: !nonReplaceableTabTypes.includes(type),
...(uid ? { folderUid: uid } : {})
};
}
state.activeTabUid = uid;
return;
return
}
state.tabs.push({
@@ -74,7 +74,6 @@ export const tabsSlice = createSlice({
requestPaneWidth: null,
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
responsePaneScrollPosition: null,
type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
@@ -127,13 +126,6 @@ export const tabsSlice = createSlice({
tab.responsePaneTab = action.payload.responsePaneTab;
}
},
updateResponsePaneScrollPosition: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.responsePaneScrollPosition = action.payload.scrollY;
}
},
closeTabs: (state, action) => {
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
@@ -175,8 +167,8 @@ export const tabsSlice = createSlice({
const tab = find(state.tabs, (t) => t.uid === uid);
if (tab) {
tab.preview = false;
} else {
console.error('Tab not found!');
} else{
console.error("Tab not found!")
}
}
}
@@ -189,7 +181,6 @@ export const {
updateRequestPaneTabWidth,
updateRequestPaneTab,
updateResponsePaneTab,
updateResponsePaneScrollPosition,
closeTabs,
closeAllCollectionTabs,
makeTabPermanent

View File

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

View File

@@ -1,16 +1,43 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, updateUidsInCollection, hydrateSeqInCollection } from './common';
export const processBrunoCollection = async (jsonData) => {
try {
let collection = hydrateSeqInCollection(jsonData);
collection = updateUidsInCollection(collection);
collection = transformItemsInCollection(collection);
await validateSchema(collection);
return collection;
} catch (err) {
console.error('Error processing Bruno collection:', err);
throw new BrunoError('Import collection failed');
}
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const parseJsonCollection = (str) => {
return new Promise((resolve, reject) => {
try {
let parsed = JSON.parse(str);
return resolve(parsed);
} catch (err) {
console.log(err);
reject(new BrunoError('Unable to parse the collection json file'));
}
});
};
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then(parseJsonCollection)
.then(hydrateSeqInCollection)
.then(updateUidsInCollection)
.then(transformItemsInCollection)
.then(validateSchema)
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
});
});
};
export default importCollection;

View File

@@ -1,26 +1,43 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { insomniaToBruno } from '@usebruno/converters';
export const convertInsomniaToBruno = (data) => {
try {
return insomniaToBruno(data);
} catch (err) {
console.error('Error converting Insomnia to Bruno:', err);
throw new BrunoError('Import collection failed: ' + err.message);
}
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
// try to load JSON
const parsedData = JSON.parse(e.target.result);
resolve(parsedData);
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
const parsedData = jsyaml.load(e.target.result, { schema: jsyaml.CORE_SCHEMA });
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
reject(new BrunoError('Import collection failed'));
}
}
};
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
export const isInsomniaCollection = (data) => {
// Check for Insomnia v5 collection format collection array must be present
if (typeof data.type === 'string' && data.type.startsWith('collection.insomnia.rest/5')) {
return Array.isArray(data.collection);
}
// Check for Insomnia v4 export format must have __export_format and resources array
if (data._type === 'export') {
return Array.isArray(data.resources) && typeof data.__export_format === 'number';
}
return false;
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then((collection) => insomniaToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
reject(new BrunoError('Import collection failed: ' + err.message));
});
});
};
export default importCollection;

View File

@@ -1,27 +1,43 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { openApiToBruno } from '@usebruno/converters';
export const convertOpenapiToBruno = (data) => {
try {
return openApiToBruno(data);
} catch (err) {
console.error('Error converting OpenAPI to Bruno:', err);
throw new BrunoError('Import collection failed: ' + err.message);
}
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
// try to load JSON
const parsedData = JSON.parse(e.target.result);
resolve(parsedData);
} catch (jsonError) {
// not a valid JSOn, try yaml
try {
const parsedData = jsyaml.load(e.target.result);
resolve(parsedData);
} catch (yamlError) {
console.error('Error parsing the file :', jsonError, yamlError);
reject(new BrunoError('Import collection failed'));
}
}
};
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
export const isOpenApiSpec = (data) => {
if (typeof data.info !== 'object' || data.info === null) {
return false;
}
if (typeof data.openapi === 'string' && data.openapi.trim().length) {
return true;
}
if (typeof data.swagger === 'string' && data.swagger.trim().length) {
return true;
}
return false;
const importCollection = () => {
return new Promise((resolve, reject) => {
fileDialog({ accept: '.json, .yaml, .yml, application/json, application/yaml, application/x-yaml' })
.then(readFile)
.then((collection) => openApiToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.error(err);
reject(new BrunoError('Import collection failed: ' + err.message));
});
});
};
export default importCollection;

View File

@@ -22,20 +22,4 @@ const postmanToBruno = (collection) => {
});
};
const isPostmanCollection = (data) => {
const info = data.info;
if (!info || typeof info !== 'object') {
return false;
}
const schema = info.schema;
// Accept schemas hosted at schema.getpostman.com or schema.postman.com
const schemaRegex = /^https:\/\/schema\.(?:getpostman|postman)\.com\//;
if (typeof schema === 'string' && schemaRegex.test(schema)) {
return true;
}
return false;
};
export { postmanToBruno, readFile, isPostmanCollection };
export { postmanToBruno, readFile };

View File

@@ -111,7 +111,7 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
}
if (!disableCookies){
saveCookies(error.config.url, error.response.headers);
saveCookies(redirectUrl, error.response.headers);
}
const requestConfig = createRedirectConfig(error, redirectUrl);

View File

@@ -1,38 +1,18 @@
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: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
});
it('should handle missing values by leaving the placeholders unchanged using {{}} as delimiters', () => {
@@ -52,7 +32,7 @@ describe('interpolate', () => {
const inputObject = {
user: {
full_name: 'Bruno',
age: BRUNO_AGE,
age: 4,
'fav-food': ['egg', 'meat'],
'want.attention': true
}
@@ -65,7 +45,7 @@ describe('interpolate', () => {
`;
const expectedStr = `
Hi, I am Bruno,
I am ${BRUNO_AGE} years old.
I am 4 years old.
My favorite food is egg and meat.
I like attention: true
`;
@@ -78,13 +58,13 @@ describe('interpolate', () => {
const inputObject = {
'user.name': 'Bruno',
user: {
age: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is {{ user.name }} and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is {{ user.name }} and I am 4 years old');
});
test('should give precedence to the last key in case of duplicates (not at the top level)', () => {
@@ -94,14 +74,14 @@ describe('interpolate', () => {
'user.name': 'Bruno',
user: {
name: 'Not _Bruno_',
age: BRUNO_AGE
age: 4
}
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is Bruno and Not _Bruno_ I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is Bruno and Not _Bruno_ I am 4 years old');
});
});
@@ -199,13 +179,13 @@ describe('interpolate - recursive', () => {
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
'user.name': 'Bruno',
user: {
age: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is Bruno and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
});
it('should replace placeholders with 2 level of recursion with values from the object', () => {
@@ -215,13 +195,13 @@ describe('interpolate - recursive', () => {
'user.name': 'Bruno {{user.lastName}}',
'user.lastName': 'Dog',
user: {
age: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
});
it('should replace placeholders with 3 level of recursion with values from the object', () => {
@@ -232,13 +212,13 @@ describe('interpolate - recursive', () => {
'user.name': 'Bruno {{user.lastName}}',
'user.lastName': 'Dog',
user: {
age: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is Bruno Dog and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is Bruno Dog and I am 4 years old');
});
it('should handle missing values with 1 level of recursion by leaving the placeholders unchanged using {{}} as delimiters', () => {
@@ -246,13 +226,13 @@ describe('interpolate - recursive', () => {
const inputObject = {
'user.message': 'Hello, my name is {{user.name}} and I am {{user.age}} years old',
user: {
age: BRUNO_AGE
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`Hello, my name is {{user.name}} and I am ${BRUNO_AGE} years old`);
expect(result).toBe('Hello, my name is {{user.name}} and I am 4 years old');
});
it('should handle all valid keys with 1 level of recursion', () => {
@@ -266,7 +246,7 @@ describe('interpolate - recursive', () => {
user: {
message,
full_name: 'Bruno',
age: BRUNO_AGE,
age: 4,
'fav-food': ['egg', 'meat'],
'want.attention': true
}
@@ -275,7 +255,7 @@ describe('interpolate - recursive', () => {
const inputStr = '{{user.message}}';
const expectedStr = `
Hi, I am Bruno,
I am ${BRUNO_AGE} years old.
I am 4 years old.
My favorite food is egg and meat.
I like attention: true
`;
@@ -381,32 +361,32 @@ describe('interpolate - object handling', () => {
it('should stringify simple objects', () => {
const inputString = 'User: {{user}}';
const inputObject = {
'user': { name: 'Bruno', age: BRUNO_AGE }
'user': { name: 'Bruno', age: 4 }
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
expect(result).toBe('User: {"name":"Bruno","age":4}');
});
it('should stringify simple objects (dot notation)', () => {
const inputString = 'User: {{user.data}}';
const inputObject = {
'user.data': { name: 'Bruno', age: BRUNO_AGE }
'user.data': { name: 'Bruno', age: 4 }
};
const result = interpolate(inputString, inputObject);
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE}}`);
expect(result).toBe('User: {"name":"Bruno","age":4}');
});
it('should stringify nested objects', () => {
const inputString = 'User: {{user}}';
const inputObject = {
'user': {
name: 'Bruno',
age: BRUNO_AGE,
preferences: {
'user': {
name: 'Bruno',
age: 4,
preferences: {
food: ['egg', 'meat'],
toys: { favorite: 'ball' }
}
@@ -415,7 +395,7 @@ describe('interpolate - object handling', () => {
const result = interpolate(inputString, inputObject);
expect(result).toBe(`User: {"name":"Bruno","age":${BRUNO_AGE},"preferences":{"food":["egg","meat"],"toys":{"favorite":"ball"}}}`);
expect(result).toBe('User: {"name":"Bruno","age":4,"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": 6
* "age": 4
* }
* });
* Output: Hello, my name is Bruno and I am 6 years old
* Output: Hello, my name is Bruno and I am 4 years old
*/
import { mockDataFunctions } from '../utils/faker-functions';

View File

@@ -31,7 +31,7 @@ const brunoEnvironment = postmanToBrunoEnvironment(postmanEnvironment);
### Convert Insomnia collection to Bruno collection
```javascript
const { insomniaToBruno } = require('@usebruno/converters');
import { insomniaToBruno } from '@usebruno/converters';
const brunoCollection = insomniaToBruno(insomniaCollection);
```
@@ -39,7 +39,7 @@ const brunoCollection = insomniaToBruno(insomniaCollection);
### Convert OpenAPI specification to Bruno collection
```javascript
const { openApiToBruno } = require('@usebruno/converters');
import { openApiToBruno } from '@usebruno/converters';
const brunoCollection = openApiToBruno(openApiSpecification);
```
@@ -75,4 +75,4 @@ const outputFilePath = path.resolve(__dirname, 'bruno-collection.json');
convertPostmanToBruno(inputFilePath, outputFilePath);
```
```

View File

@@ -60,9 +60,7 @@ const transformOpenapiRequestItem = (request) => {
mode: 'inherit',
basic: null,
bearer: null,
digest: null,
apikey: null,
oauth2: null
digest: null
},
headers: [],
params: [],
@@ -110,16 +108,13 @@ const transformOpenapiRequestItem = (request) => {
}
});
// 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;
let auth;
// allow operation override
if (_operationObject.security && _operationObject.security.length > 0) {
const schemeName = Object.keys(_operationObject.security[0])[0];
let schemeName = Object.keys(_operationObject.security[0])[0];
auth = request.global.security.getScheme(schemeName);
} else if (request.global.security.supported.length > 0) {
auth = request.global.security.supported[0];
}
if (auth) {
@@ -134,87 +129,14 @@ const transformOpenapiRequestItem = (request) => {
brunoRequestItem.request.auth.bearer = {
token: '{{token}}'
};
} 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,
} else if (auth.type === 'apiKey' && auth.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: auth.name,
value: '{{apiKey}}',
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
};
description: 'Authentication header',
enabled: true
});
}
}
@@ -503,9 +425,7 @@ export const parseOpenApiCollection = (data) => {
mode: 'inherit',
basic: null,
bearer: null,
digest: null,
apikey: null,
oauth2: null
digest: null
}
},
meta: {
@@ -519,103 +439,6 @@ 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

@@ -350,9 +350,6 @@ 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);
@@ -790,102 +787,5 @@ 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

@@ -1,143 +0,0 @@
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

@@ -1,299 +0,0 @@
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,7 +174,13 @@ 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(error.config.url, error.response.headers);
saveCookies(redirectUrl, error.response.headers);
}
// Create a new request config for the redirect

View File

@@ -56,13 +56,7 @@ const grammar = ohm.grammar(`Bru {
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
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
pair = st* key st* ":" st* value st*
key = keychar*
value = list | multilinetextblock | valuechar*
@@ -307,14 +301,6 @@ 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() : '';
},
@@ -378,9 +364,6 @@ const sem = grammar.createSemantics().addAttribute('ast', {
tagend(_1, _2) {
return '';
},
_terminal(){
return this.sourceString;
},
multilinetextblockdelimiter(_) {
return '';
},

View File

@@ -4,10 +4,6 @@ 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) => {
@@ -125,7 +121,7 @@ const jsonToBru = (json) => {
if (enabled(queryParams).length) {
bru += `\n${indentString(
enabled(queryParams)
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
}
@@ -133,7 +129,7 @@ const jsonToBru = (json) => {
if (disabled(queryParams).length) {
bru += `\n${indentString(
disabled(queryParams)
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
}
@@ -155,7 +151,7 @@ const jsonToBru = (json) => {
if (enabled(headers).length) {
bru += `\n${indentString(
enabled(headers)
.map((item) => `${quoteKey(item.name)}: ${item.value}`)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
}
@@ -163,7 +159,7 @@ const jsonToBru = (json) => {
if (disabled(headers).length) {
bru += `\n${indentString(
disabled(headers)
.map((item) => `~${quoteKey(item.name)}: ${item.value}`)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
}
@@ -250,7 +246,7 @@ ${indentString(`domain: ${auth?.ntlm?.domain || ''}`)}
}
`;
}
}
if (auth && auth.oauth2) {
switch (auth?.oauth2?.grantType) {
@@ -500,14 +496,14 @@ ${indentString(body.sparql)}
if (enabled(body.formUrlEncoded).length) {
const enabledValues = enabled(body.formUrlEncoded)
.map((item) => `${quoteKey(item.name)}: ${getValueString(item.value)}`)
.map((item) => `${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(enabledValues)}\n`;
}
if (disabled(body.formUrlEncoded).length) {
const disabledValues = disabled(body.formUrlEncoded)
.map((item) => `~${quoteKey(item.name)}: ${getValueString(item.value)}`)
.map((item) => `~${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(disabledValues)}\n`;
}
@@ -528,7 +524,7 @@ ${indentString(body.sparql)}
item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
if (item.type === 'text') {
return `${enabled}${quoteKey(item.name)}: ${getValueString(item.value)}${contentType}`;
return `${enabled}${item.name}: ${getValueString(item.value)}${contentType}`;
}
if (item.type === 'file') {
@@ -536,7 +532,7 @@ ${indentString(body.sparql)}
const filestr = filepaths.join('|');
const value = `@file(${filestr})`;
return `${enabled}${quoteKey(item.name)}: ${value}${contentType}`;
return `${enabled}${item.name}: ${value}${contentType}`;
}
})
.join('\n')

View File

@@ -81,25 +81,6 @@ 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,11 +17,6 @@ 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
}
@@ -32,11 +27,6 @@ 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}}
}
@@ -114,23 +104,13 @@ 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,36 +24,6 @@
"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",
@@ -78,31 +48,6 @@
"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}}",
@@ -173,35 +118,10 @@
"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": [
@@ -219,47 +139,12 @@
"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,16 +89,9 @@ export function addDigestInterceptor(axiosInstance, request) {
authDetails.algorithm = 'MD5';
}
// 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 uri = new URL(request.url, request.baseURL || 'http://localhost').pathname; // Handle relative URLs
const HA1 = md5(`${username}:${authDetails.realm}:${password}`);
const HA2 = md5(`${method}:${uri}`);
const HA2 = md5(`${request.method}:${uri}`);
const response = md5(
`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`
);

View File

@@ -1,58 +0,0 @@
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

@@ -1,32 +0,0 @@
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,28 +38,9 @@ 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: brunoAge,
age: 5,
'fav-food': ['egg', 'meat'],
'want.attention': true
});
@@ -67,27 +48,8 @@ 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 ${brunoAge} years old.
I am 5 years old.
My favorite food is egg and meat.
I like attention: true`;
expect(res.getBody()).to.equal(expectedResponse);

View File

@@ -8,20 +8,19 @@
"name": "@usebruno/test-collection",
"version": "0.0.1",
"dependencies": {
"@faker-js/faker": "^8.4.1"
"@faker-js/faker": "^8.4.0"
}
},
"node_modules/@faker-js/faker": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.0.tgz",
"integrity": "sha512-htW87352wzUCdX1jyUQocUcmAaFqcR/w082EC8iP/gtkF0K+aKcBp0hR5Arb7dzR8tQ1TrhE9DNa5EbJELm84w==",
"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.1"
"@faker-js/faker": "^8.4.0"
}
}