Merge branch 'main' into folder_sequencing_cli

This commit is contained in:
lohit
2025-05-09 16:34:50 +05:30
116 changed files with 8827 additions and 945 deletions

3
.gitignore vendored
View File

@@ -46,4 +46,5 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.idea
.vscode

3
.husky/pre-commit Executable file
View File

@@ -0,0 +1,3 @@
# .husky/pre-commit
npx lint-staged

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.browser,
...globals.jest,
global: false,
require: false,
Buffer: false,
process: false
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
}
]);

2365
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,12 @@
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
"husky": "^8.0.3",
"jest": "^29.2.0",
"lint-staged": "^15.5.2",
"lodash-es": "^4.17.21",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
@@ -59,7 +62,8 @@
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install"
"prepare": "husky install",
"lint": "npx eslint ./"
},
"overrides": {
"rollup": "3.29.5",
@@ -68,5 +72,8 @@
"json-schema-typed": "8.0.1"
}
}
},
"lint-staged": {
"packages/**/*.{js,jsx,ts,tsx}": "npm run lint"
}
}

View File

@@ -19,7 +19,7 @@ export default defineConfig({
})
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
},
html: {
title: 'Bruno'
@@ -34,6 +34,16 @@ export default defineConfig({
},
},
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
],
// Add externals configuration to exclude Node.js libraries
externals: {
// List specific Node.js modules you want to exclude
// Format: 'module-name': 'commonjs module-name'
'worker_threads': 'commonjs worker_threads',
// 'path': 'commonjs path'
}
},
}
});

View File

@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
</div>
</div>
</div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={handleToggleShowShareCollectionModal(false)} />}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
collection={collection}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}

View File

@@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
const folderRoot = folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { find } from "lodash";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';

View File

@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
</StyledWrapper>
);

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const FolderNotFound = ({ folderUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = useCallback(() => {
dispatch(
closeTabs({
tabUids: [folderUid]
})
);
}, [dispatch, folderUid]);
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
if (!showErrorMessage) {
return null;
}
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Folder no longer exists.</div>
<div className="mt-2">
This can happen when the folder was renamed or deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
Close Tab
</button>
</div>
);
};
export default FolderNotFound;

View File

@@ -25,7 +25,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -165,11 +165,7 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;

View File

@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
@@ -261,13 +263,14 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
<NewRequest collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
collectionUid={collection.uid}
collectionPathname={collection.pathname}
onClose={() => setShowCloneRequestModal(false)}
/>
)}

View File

@@ -79,7 +79,7 @@ const RequestTabs = () => {
return (
<StyledWrapper className={getRootClassname()}>
{newRequestModalOpen && (
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest collectionUid={activeCollection?.uid} collectionPathname={activeCollection?.pathname} onClose={() => setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>

View File

@@ -2,14 +2,20 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
if (!Number.isFinite(size)) {
return null;
}
let sizeToDisplay = '';
// If size is greater than 1024 bytes, format as KB
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
// If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}

View File

@@ -48,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -156,7 +157,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
<ResponseSize size={responseSize} />
</>
) : null}
</div>

View File

@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => {
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -11,11 +11,13 @@ import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
const CloneCollection = ({ onClose, collection }) => {
const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const { name } = collection;
const formik = useFormik({
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
collection.pathname
collection?.pathname
)
)
.then(() => {

View File

@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => {
const CloneCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -172,8 +172,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
dirName={path.relative(collectionPathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.drag-preview {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,49 @@
import { useDragLayer } from 'react-dnd';
import {
IconFile,
IconFolder,
} from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
function getItemStyles({ x, y }) {
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
const transform = `translate(${x}px, ${y}px)`;
return {
position: 'fixed',
pointerEvents: 'none',
top: 0,
transform,
WebkitTransform: transform,
zIndex: 100,
};
}
export const CollectionItemDragPreview = () => {
const {
item,
isDragging,
clientOffset
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = !item.type || item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
{shouldShowFolderIcon ? (
<IconFolder size={16} />
) : (
<IconFile size={16} />
)}
{item.name}
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DeleteCollectionItem = ({ onClose, item, collection }) => {
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder

View File

@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
<CodeEditor
readOnly
collection={collection}
item={item}
value={snippet}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}

View File

@@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => {
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });

View File

@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const RenameCollectionItem = ({ collectionUid, collectionPathname, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return;
}
if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
await dispatch(saveRequest(item.uid, collectionUid, true));
}
const { name: newName, filename: newFilename } = values;
try {
let renameConfig = {
itemUid: item.uid,
collectionUid: collection.uid,
collectionUid,
};
renameConfig['newName'] = newName;
if (itemFilename !== newFilename) {
@@ -191,8 +191,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
dirName={path.relative(collectionPathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -2,16 +2,18 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -34,8 +36,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
position: relative;
/* Common styles for drop indicators */
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.dragAndDrop.border};
opacity: 0;
pointer-events: none;
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
}
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
}
}
/* Inside drop target style */
&.drop-target {
&::before {
top: 0;
bottom: 0;
height: 100%;
opacity: 1;
background: ${(props) => props.theme.dragAndDrop.hoverBg};
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
// border-radius: 4px;
}
}
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
&.item-target {
background: #ccc3;
}
&.item-seperator {
.seperator {
bottom: 0px;
position: absolute;
height: 3px;
width: 100%;
background: #ccc3;
}
}
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { handleCollectionItemDrop, moveItem, sendRequest, showInFolder, updateItemsSequences } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
// We use a single ref for drag and drop.
const ref = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const isFolder = isItemAFolder(item);
const [{ isDragging }, drag] = useDrag({
type: `collection-item-${collection.uid}`,
item: item,
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
type: `collection-item-${collectionUid}`,
item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
});
const [{ isOver }, drop] = useDrop({
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
if (isItemAFolder(item)) {
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
return clientY < fileUpperThreshold ? 'adjacent' : null;
}
};
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
if (draggedItemUid === targetItemUid) return false;
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
return true;
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
setDropType(_canItemBeDropped ? dropType : null);
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
drop: async (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
if (!dropType) return;
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
setDropType(null);
},
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
collect: (monitor) => ({
isOver: monitor.isOver(),
isOver: monitor.isOver()
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
const itemRowClassName = classnames('flex collection-item-name items-center', {
'item-focused-in-tab': item.uid == activeTabUid,
'item-hovered': isOver
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isTabForItemActive,
'item-hovered': isOver && canDrop,
'drop-target': isOver && dropType === 'inside',
'drop-target-above': isOver && dropType === 'adjacent'
});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
type: 'folder-settings',
})
);
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
dispatch(makeTabPermanent({ uid: item.uid }));
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
// Sort items by their "seq" property.
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
return;
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (
(item?.request?.url !== '') ||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (isTabForItemPresent) {
dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid,
type: 'folder-settings'
})
);
}
};
return (
<StyledWrapper className={className}>
{renameItemModalOpen && (
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
<RenameCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
<CloneCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
<DeleteCollectionItem item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
<NewFolder item={item} collectionUid={collectionUid} collectionPathname={collectionPathname} onClose={() => setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
{itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
)}
<div className={itemRowClassName} ref={collectionItemRef}>
<div
className={itemRowClassName}
ref={(node) => {
ref.current = node;
drag(drop(node));
}}
>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
return (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
width: 16,
minWidth: 16,
height: '100%'
}}
>
&nbsp;{/* Indent */}
</div>
);
})
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
>
&nbsp;{/* Indent */}
</div>
))
: null}
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
}}
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
/>
) : null}
</div>
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
</div>
</div>
{!itemIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
</div>
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default React.memo(CollectionItem);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RemoveCollection = ({ onClose, collection }) => {
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))

View File

@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RenameCollection = ({ collection, onClose }) => {
const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {

View File

@@ -13,7 +13,8 @@ const Wrapper = styled.div`
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-bottom: 2px solid transparent;
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
color: white;
}
}
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-above {
border: none;
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-top: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-below {
border: none;
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-bottom: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {

View File

@@ -1,4 +1,5 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
@@ -6,8 +7,8 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
const [{ isDragging }, drag, dragPreview] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
}),
});
drag(drop(collectionRef));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
{showNewRequestModal && <NewRequest collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
<RenameCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
<RemoveCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
<ShareCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
<CloneCollection collectionUid={collection.uid} collectionPathname={collection.pathname} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<CollectionItemDragPreview />
<div className={collectionRowClassName}
ref={collectionRef}
ref={(node) => {
collectionRef.current = node;
drag(drop(node));
}}
>
<div
className="flex flex-grow items-center overflow-hidden"
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
</Dropdown>
</div>
</div>
<div>
{!collectionIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
{requestItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
</div>
) : null}
</div>

View File

@@ -1,34 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false)
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'));
.catch((err) => toastError(err, 'Import collection failed'))
};
const handleImportPostmanCollection = () => {
importPostmanCollection()
.then(({ collection }) => {
handleSubmit({ collection });
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
}
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const handleImportOpenapiCollection = () => {
@@ -36,8 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
@@ -50,18 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</button>
);
};
return (
<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>
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

@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => {
const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
})
}),
onSubmit: (values) => {
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => {
toast.success('New folder created!');
onClose();

View File

@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid
collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
</div>

View File

@@ -13,12 +13,9 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
refreshUidsInItem,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
@@ -47,8 +44,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
@@ -60,7 +56,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
});
};
@@ -337,6 +333,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
})
);
const { ipcRenderer } = window;
ipcRenderer
.invoke(
'renderer:run-collection-folder',
@@ -358,6 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => {
if (!collection) {
@@ -372,10 +371,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
if (!folderWithSameNameExists) {
const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -392,8 +408,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -495,8 +529,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
set(item, 'name', newName);
set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName);
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
const collectionPath = path.join(parentFolder.pathname, newFilename);
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
@@ -594,176 +631,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
.then(resolve)
.catch(reject);
});
}
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
await dispatch(moveItem({
targetDirname: newDirname,
sourcePathname: draggedItemPathname
}));
// Update sequences in the source directory
if (draggedItemDirectoryItems?.length) {
// reorder items in the source directory
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
pathname: newPathname,
seq: targetItemSequence
};
// draggedItem is added to the targetItem's directory
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
targetItemUid,
draggedItemUid
});
if (reorderedTargetItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
}
}
};
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;
// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
});
if (reorderedItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
}
};
return new Promise(async (resolve, reject) => {
try {
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
}
resolve();
} catch (error) {
console.error(error);
toast.error(error?.message);
reject(error);
}
})
}
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
const sameParent = draggedItemParent === targetItemParent;
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item which is at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item and both are in different folders
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged into its own folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// file item dragged into another folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// end of the file drags, now let's handle folder drags
// folder drags are simpler since we don't allow ordering of folders
// folder dragged into its own folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// folder dragged into a file which is at the same level
// this is also true when both items are at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
return resolve();
}
// folder dragged into a file which is a child of the folder
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
return resolve();
}
// folder dragged into a file which is at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
}
// folder dragged into another folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
.catch((error) => reject(error));
}
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch(reject);
});
};
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
// file item is already at the root level
if (!draggedItemParent) {
return resolve();
}
const draggedItemPathname = draggedItem.pathname;
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
if (isItemAFolder(draggedItem)) {
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
} else {
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
});
};
}
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
@@ -823,8 +798,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(collection.pathname, resolvedFilename);
@@ -852,8 +827,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window;
@@ -885,6 +860,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(
@@ -913,6 +889,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
@@ -946,6 +923,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
@@ -982,6 +960,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
const oldName = environment.name;
environment.name = sanitizedName;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
@@ -1005,6 +984,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-environment', collection.pathname, environment.name)
.then(resolve)
@@ -1028,6 +1008,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
@@ -1053,7 +1034,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
if (environmentUid && !environmentName) {
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
@@ -1112,11 +1094,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
.then(resolve)
@@ -1135,6 +1119,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
brunoConfig: brunoConfig
};
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
collectionSchema

View File

@@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
}
return;
}
@@ -1795,9 +1798,10 @@ export const collectionsSlice = createSlice({
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
uid: dir?.meta?.uid || uuid(),
pathname: currentPath,
name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq || 1,
filename: directoryName,
collapsed: true,
type: 'folder',
@@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data;
}
return;

View File

@@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
@@ -90,6 +90,7 @@ export const {
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
if (!globalEnvironmentVariables) resolve();
const state = getState();

View File

@@ -0,0 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
export const isTabForItemActive = ({ itemUid }) => createSelector([
(state) => state.tabs?.activeTabUid
], (activeTabUid) => activeTabUid === itemUid);
export const isTabForItemPresent = ({ itemUid }) => createSelector([
(state) => state.tabs.tabs,
], (tabs) => tabs.some((tab) => tab.uid === itemUid));

View File

@@ -281,6 +281,12 @@ const darkTheme = {
color: 'rgb(52 51 49)'
},
dragAndDrop: {
border: '#666666',
borderStyle: '2px solid',
hoverBg: 'rgba(102, 102, 102, 0.08)',
transition: 'all 0.1s ease'
},
infoTip: {
bg: '#1f1f1f',
border: '#333333',

View File

@@ -282,6 +282,12 @@ const lightTheme = {
color: 'rgb(152 151 149)'
},
dragAndDrop: {
border: '#8b8b8b', // Using the same gray as focusBorder from input
borderStyle: '2px solid',
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
transition: 'all 0.1s ease'
},
infoTip: {
bg: 'white',
border: '#e0e0e0',

View File

@@ -5,6 +5,8 @@
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
*/
import { JSHINT } from 'jshint';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;

View File

@@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
export const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items);
@@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
};
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
}
};
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if (!draggedItemParent) {
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
} else {
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
};
export const getItemsToResequence = (parent, collection) => {
let itemsToResequence = [];
if (!parent) {
let index = 1;
each(collection.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
return itemsToResequence;
};
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
@@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (meta?.name) {
di.root.meta = {};
di.root.meta.name = meta?.name;
di.root.meta.seq = meta?.seq;
}
if (!Object.keys(di.root.request)?.length) {
delete di.root.request;
@@ -1086,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
});
return credentialsVariables;
};
// item sequence utils - START
export const resetSequencesInFolder = (folderItems) => {
const items = folderItems;
const sortedItems = items.sort((a, b) => a.seq - b.seq);
return sortedItems.map((item, index) => {
item.seq = index + 1;
return item;
});
};
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
if (targetItemSequence > sourceItemSequence) {
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
}
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
};
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
if (!isDraggedItem) {
return null;
}
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
const targetSequence = targetItem?.seq;
const draggedSequence = draggedItem?.seq;
itemsWithFixedSequences?.forEach(item => {
const isDraggedItem = item?.uid === draggedItemUid;
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
if (isBetween) {
item.seq += targetSequence > draggedSequence ? -1 : 1;
}
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
if (newSequence !== null) {
item.seq = newSequence;
}
});
// only return items that have been reordered
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const getReorderedItemsInSourceDirectory = ({ items }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
const { pathname: targetItemPathname } = targetItem;
const { filename: draggedItemFilename } = draggedItem;
const targetItemDirname = path.dirname(targetItemPathname);
const isTargetTheCollection = targetItemPathname === collectionPathname;
const isTargetItemAFolder = isItemAFolder(targetItem);
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename)
} else if (dropType === 'adjacent') {
return path.join(targetItemDirname, draggedItemFilename)
}
return null;
};
// item sequence utils - END

View File

@@ -1,10 +0,0 @@
class Cache {
get(key) {
return window.localStorage.getItem(key);
}
set(key, val) {
window.localStorage.setItem(key, val);
}
}
module.exports = new Cache();

View File

@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
export const convertToCodeMirrorJson = (obj) => {
try {
return JSON5.stringify(obj).slice(1, -1);
return JSON.stringify(obj, null, 2).slice(1, -1);
} catch (e) {
return obj;
}

View File

@@ -1,16 +1,16 @@
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
export const variableNameRegex = /^[\w-.]*$/;
export const sanitizeName = (name) => {
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
return name;
};

View File

@@ -23,8 +23,8 @@ describe('regex validators', () => {
});
it('should remove trailing periods', () => {
expect(sanitizeName('.file')).toBe('file');
expect(sanitizeName('.file.')).toBe('file');
expect(sanitizeName('.file')).toBe('.file');
expect(sanitizeName('.file.')).toBe('.file');
expect(sanitizeName('file.')).toBe('file');
expect(sanitizeName('file.name.')).toBe('file.name');
expect(sanitizeName('hello world.')).toBe('hello world');
@@ -83,11 +83,11 @@ describe('regex validators', () => {
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
expect(sanitizeName('file.name...')).toBe('file.name');
expect(sanitizeName('...file')).toBe('file');
expect(sanitizeName('...file')).toBe('...file');
expect(sanitizeName('file.name... ')).toBe('file.name');
expect(sanitizeName(' ...file')).toBe('file');
expect(sanitizeName(' ...file ')).toBe('file');
expect(sanitizeName(' ...file.... ')).toBe('file');
expect(sanitizeName(' ...file')).toBe('...file');
expect(sanitizeName(' ...file ')).toBe('...file');
expect(sanitizeName(' ...file.... ')).toBe('...file');
});
it('should handle very long filenames', () => {

View File

@@ -1,6 +1,5 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { postmanToBruno } from '@usebruno/converters';
import { safeParseJSON } from 'utils/common/index';
const readFile = (files) => {
@@ -12,18 +11,15 @@ const readFile = (files) => {
});
};
const importCollection = () => {
const postmanToBruno = (collection) => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then((collection) => postmanToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
})
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
.then(result => resolve(result))
.catch(err => {
console.error('Error converting Postman to Bruno via Electron:', err);
reject(new BrunoError('Conversion failed'));
});
});
};
export default importCollection;
export { postmanToBruno, readFile };

View File

@@ -0,0 +1,126 @@
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
describe('resetSequencesInFolder', () => {
it('should fix the sequences in the folder 1', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 6 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
]);
});
it('should fix the sequences in the folder 2', () => {
const folder = {
items: [
{ uid: '1', seq: 3 },
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
{ uid: '1', seq: 3 },
]);
});
it('should fix the sequences in the folder with missing sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', type: 'folder' },
{ uid: '3', type: 'folder' },
{ uid: '4', seq: 7 },
]
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2, type: 'folder' },
{ uid: '3', seq: 3, type: 'folder' },
{ uid: '4', seq: 4 },
]);
});
it('should fix the sequences in the folder with same sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 2 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
{ uid: '4', seq: 1 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '4', seq: 1 },
{ uid: '1', seq: 2 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 4 },
]);
});
});
describe('isItemBetweenSequences', () => {
it('should return true if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 3', () => {
const item = { uid: '1', seq: 4 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 4', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 5;
const targetSequence = 1;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return false if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
it('should return false if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 5 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
});

View File

@@ -52,6 +52,7 @@
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"@usebruno/requests": "^0.1.0",
"@usebruno/converters": "^0.1.0",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
@@ -63,6 +64,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",

View File

@@ -58,6 +58,44 @@ If you need to limit the trusted CA to a specified set when validating the reque
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
```
## Importing Collections
You can import collections from other formats, such as OpenAPI, using the import command:
```bash
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
```
You can also use the shorter form with aliases:
```bash
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
```
This creates a Bruno collection directory that can be opened in Bruno.
You can also import directly from a URL:
```bash
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
```
You can also export the collection as a JSON file:
```bash
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
```
Import Options:
| Option | Details |
| ------------------------- | -------------------------------------------------- |
| --source, -s | Path to the source file or URL (required) |
| --output, -o | Path to the output directory |
| --output-file, -f | Path to the output JSON file |
| --collection-name, -n | Name for the imported collection |
| --insecure | Skip SSL certificate validation when fetching from URLs |
## Command Line Options
| Option | Details |

View File

@@ -0,0 +1,230 @@
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const jsyaml = require('js-yaml');
const axios = require('axios');
const { openApiToBruno } = require('@usebruno/converters');
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
const { createCollectionFromBrunoObject } = require('../utils/collection');
const command = 'import <type>';
const desc = 'Import a collection from other formats';
const builder = (yargs) => {
yargs
.positional('type', {
describe: 'Type of collection to import',
type: 'string',
choices: ['openapi']
})
.option('source', {
alias: 's',
describe: 'Path to the source file or URL',
type: 'string',
demandOption: true
})
.option('output', {
alias: 'o',
describe: 'Path to the output directory',
type: 'string',
conflicts: 'output-file'
})
.option('output-file', {
alias: 'f',
describe: 'Path to the output JSON file',
type: 'string',
conflicts: 'output'
})
.option('collection-name', {
alias: 'n',
describe: 'Name for the imported collection',
type: 'string'
})
.option('insecure', {
type: 'boolean',
describe: 'Skip SSL certificate verification when fetching from URLs',
default: false
})
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
};
const isUrl = (str) => {
try {
return Boolean(new URL(str));
} catch (error) {
return false;
}
};
const readOpenApiFile = async (source, options = {}) => {
try {
let content;
if (isUrl(source)) {
// Handle URL input
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
try {
const axiosOptions = {
timeout: 30000, // 30 second timeout
maxContentLength: 10 * 1024 * 1024,
validateStatus: status => status >= 200 && status < 300
};
// Skip SSL certificate validation if insecure flag is set
if (options.insecure) {
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
}
const response = await axios.get(source, axiosOptions);
content = response.data;
} catch (error) {
if (error.code === 'ECONNABORTED') {
throw new Error('Request timed out. The server took too long to respond.');
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
} else if (error.response) {
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
throw new Error(`No response received from server. Check the URL and your network connection.`);
} else {
throw new Error(`Error fetching URL: ${error.message}`);
}
}
// If response is already an object, return it directly
if (typeof content === 'object' && content !== null) {
return content;
}
} else {
// Handle file input
if (!await exists(source)) {
throw new Error(`File does not exist: ${source}`);
}
content = fs.readFileSync(source, 'utf8');
}
// If content is a string, try to parse as JSON or YAML
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (jsonError) {
try {
return jsyaml.load(content);
} catch (yamlError) {
throw new Error('Failed to parse content as JSON or YAML');
}
}
}
return content;
} catch (error) {
// Let the specific error handling from above propagate
throw error;
}
};
const handler = async (argv) => {
try {
const { type, source, output, outputFile, collectionName, insecure } = argv;
if (!type || type !== 'openapi') {
console.error(chalk.red('Only OpenAPI import is supported currently'));
process.exit(1);
}
if (!source) {
console.error(chalk.red('Source file or URL is required'));
process.exit(1);
}
if (!output && !outputFile) {
console.error(chalk.red('Either --output or --output-file is required'));
process.exit(1);
}
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
const openApiSpec = await readOpenApiFile(source, { insecure });
if (!openApiSpec) {
console.error(chalk.red('Failed to parse OpenAPI specification'));
process.exit(1);
}
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
// Convert OpenAPI to Bruno format
let brunoCollection = openApiToBruno(openApiSpec);
// Override collection name if provided
if (collectionName) {
brunoCollection.name = collectionName;
}
if (outputFile) {
// Save as JSON file
const outputPath = path.resolve(outputFile);
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
} else if (output) {
const resolvedOutput = path.resolve(output);
// Check if output is an existing directory
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
// Determine the final output directory
let outputDir;
if (isOutputDirectory) {
// If output is an existing directory, use collection name to create a subdirectory
const dirName = sanitizeName(brunoCollection.name);
outputDir = path.join(resolvedOutput, dirName);
// Check if this subfolder already exists
if (await exists(outputDir)) {
const dirContents = fs.readdirSync(outputDir);
if (dirContents.length > 0) {
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
process.exit(1);
}
} else {
// Create the subfolder
fs.mkdirSync(outputDir, { recursive: true });
}
} else {
// If output doesn't exist or is not a directory, use it directly
outputDir = resolvedOutput;
// Check if parent directory exists
const parentDir = path.dirname(outputDir);
if (!await exists(parentDir)) {
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
}
await createCollectionFromBrunoObject(brunoCollection, outputDir);
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
};
module.exports = {
command,
desc,
builder,
handler,
isUrl,
readOpenApiFile
};

View File

@@ -1,5 +1,9 @@
const { get, each, find, compact } = require('lodash');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
const { sanitizeName } = require('./filesystem');
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -219,6 +223,136 @@ const getAllRequestsInFolder = (folderItems = [], recursive = true) => {
const getAllRequestsAtFolderRoot = (folderItems = []) => {
return getAllRequestsInFolder(folderItems, false);
}
/**
* Safe write file implementation to handle errors
* @param {string} filePath - Path to write file
* @param {string} content - Content to write
*/
const safeWriteFileSync = (filePath, content) => {
try {
fs.writeFileSync(filePath, content, { encoding: 'utf8' });
} catch (error) {
console.error(`Error writing file ${filePath}:`, error);
}
};
/**
* Creates a Bruno collection directory structure from a Bruno collection object
*
* @param {Object} collection - The Bruno collection object
* @param {string} dirPath - The output directory path
*/
const createCollectionFromBrunoObject = async (collection, dirPath) => {
// Create bruno.json
const brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
fs.writeFileSync(
path.join(dirPath, 'bruno.json'),
JSON.stringify(brunoConfig, null, 2)
);
// Create collection.bru if root exists
if (collection.root) {
const collectionContent = await jsonToCollectionBru(collection.root);
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
}
// Process environments
if (collection.environments && collection.environments.length) {
const envDirPath = path.join(dirPath, 'environments');
fs.mkdirSync(envDirPath, { recursive: true });
for (const env of collection.environments) {
const content = await envJsonToBruV2(env);
const filename = sanitizeName(`${env.name}.bru`);
fs.writeFileSync(path.join(envDirPath, filename), content);
}
}
// Process collection items
await processCollectionItems(collection.items, dirPath);
return dirPath;
};
/**
* Recursively processes collection items to create files and folders
*
* @param {Array} items - Collection items
* @param {string} currentPath - Current directory path
*/
const processCollectionItems = async (items = [], currentPath) => {
for (const item of items) {
if (item.type === 'folder') {
// Create folder
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath, { recursive: true });
// Create folder.bru file if root exists
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
if (item.seq) {
item.root.meta.seq = item.seq;
}
const folderContent = await jsonToCollectionBru(
item.root,
true
);
safeWriteFileSync(folderBruFilePath, folderContent);
}
// Process folder items recursively
if (item.items && item.items.length) {
await processCollectionItems(item.items, folderPath);
}
} else if (['http-request', 'graphql-request'].includes(item.type)) {
// Create request file
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
if (!sanitizedFilename.endsWith('.bru')) {
sanitizedFilename += '.bru';
}
// Convert JSON to BRU format based on the item type
let type = item.type === 'http-request' ? 'http' : 'graphql';
const bruJson = {
meta: {
name: item.name,
type: type,
seq: typeof item.seq === 'number' ? item.seq : 1
},
http: {
method: (item.request?.method || 'GET').toLowerCase(),
url: item.request?.url || '',
auth: item.request?.auth?.mode || 'none',
body: item.request?.body?.mode || 'none'
},
params: item.request?.params || [],
headers: item.request?.headers || [],
auth: item.request?.auth || {},
body: item.request?.body || {},
script: item.request?.script || {},
vars: {
req: item.request?.vars?.req || [],
res: item.request?.vars?.res || []
},
assertions: item.request?.assertions || [],
tests: item.request?.tests || '',
docs: item.request?.docs || ''
};
// Convert to BRU format and write to file
const content = await jsonToBruV2(bruJson);
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
}
}
};
module.exports = {
@@ -228,5 +362,6 @@ module.exports = {
findItemInCollection,
getTreePathFromCollectionToItem,
getAllRequestsInFolder,
getAllRequestsAtFolderRoot
getAllRequestsAtFolderRoot,
createCollectionFromBrunoObject
}

View File

@@ -118,6 +118,46 @@ const getSubDirectories = (dir) => {
}
};
/**
* Sanitizes a filename to make it safe for filesystem operations
*
* @param {string} name - The name to sanitize
* @returns {string} - The sanitized name
*/
const sanitizeName = (name) => {
if (!name) return '';
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
return name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
};
/**
* Validates if a name is valid for the filesystem
*
* @param {string} name - The name to validate
* @returns {boolean} - True if the name is valid, false otherwise
*/
const validateName = (name) => {
if (!name) return false;
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
if (name.length > 255) return false; // max name length
if (reservedDeviceNames.test(name)) return false; // windows reserved names
return (
firstCharacter.test(name) &&
middleCharacters.test(name) &&
lastCharacter.test(name)
);
};
module.exports = {
exists,
isSymbolicLink,
@@ -131,5 +171,7 @@ module.exports = {
searchForFiles,
searchForBruFiles,
stripExtension,
getSubDirectories
getSubDirectories,
sanitizeName,
validateName
};

View File

@@ -1,6 +1,5 @@
import { T_RunnerResults } from "../../types";
import { isHtmlContentType, getContentType, redactImageData, encodeBase64 } from "../../utils";
import { getRunnerSummary } from "../../runner-summary";
import htmlTemplateString from "./template";
const generateHtmlReport = ({
@@ -8,7 +7,7 @@ const generateHtmlReport = ({
}: {
runnerResults: T_RunnerResults[]
}): string => {
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results }) => {
const resultsWithSummaryAndCleanData = runnerResults.map(({ iterationIndex, results, summary }) => {
return {
iterationIndex,
results: results.map((result) => {
@@ -29,7 +28,7 @@ const generateHtmlReport = ({
}
}
}),
summary: getRunnerSummary(results)
summary
}
});
const htmlString = htmlTemplateString(encodeBase64(JSON.stringify(resultsWithSummaryAndCleanData)));

View File

@@ -96,6 +96,7 @@ export type T_RunnerResults = {
iterationIndex: number;
iterationData?: any; // todo - csv/json row data
results: T_RunnerRequestExecutionResult[];
summary: T_RunSummary;
}
// run summary type

View File

@@ -21,6 +21,7 @@
"dependencies": {
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.0",
"jscodeshift": "^17.3.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8"
},
@@ -31,6 +32,7 @@
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"@web/rollup-plugin-copy": "^0.5.1",
"babel-jest": "^29.7.0",
"rimraf": "^5.0.7",
"rollup": "3.2.5",

View File

@@ -2,6 +2,7 @@ const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const { terser } = require('rollup-plugin-terser');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const { copy } = require('@web/rollup-plugin-copy');
const packageJson = require('./package.json');
const alias = require('@rollup/plugin-alias');
@@ -12,12 +13,12 @@ module.exports = [
input: 'src/index.js',
output: [
{
file: packageJson.main,
dir: path.dirname(packageJson.main),
format: 'cjs',
sourcemap: true
},
{
file: packageJson.module,
dir: path.dirname(packageJson.module),
format: 'esm',
sourcemap: true
}
@@ -32,6 +33,10 @@ module.exports = [
terser(),
alias({
entries: [{ find: 'src', replacement: path.resolve(__dirname, 'src') }]
}),
copy({
patterns: 'src/workers/scripts/**/*',
rootDir: '.'
})
]
}

View File

@@ -47,6 +47,7 @@ export const validateSchema = (collection = {}) => {
collectionSchema.validateSync(collection);
return collection;
} catch (err) {
console.log("Error validating schema", err);
throw new Error('The Collection has an invalid schema');
}
};

View File

@@ -0,0 +1,3 @@
import { invalidVariableCharacterRegex } from './regex';
export { invalidVariableCharacterRegex };

View File

@@ -0,0 +1 @@
export const invalidVariableCharacterRegex = /[^\w-.]/g;

View File

@@ -2,4 +2,5 @@ export { default as postmanToBruno } from './postman/postman-to-bruno.js';
export { default as postmanToBrunoEnvironment } from './postman/postman-env-to-bruno-env.js';
export { default as brunoToPostman } from './postman/bruno-to-postman.js';
export { default as openApiToBruno } from './openapi/openapi-to-bruno.js';
export { default as insomniaToBruno } from './insomnia/insomnia-to-bruno.js';
export { default as insomniaToBruno } from './insomnia/insomnia-to-bruno.js';
export { default as postmanTranslation } from './postman/postman-translations.js';

View File

@@ -1,4 +1,7 @@
import each from 'lodash/each';
import { invalidVariableCharacterRegex } from '../constants';
import { uuid } from '../common';
const isSecret = (type) => {
return type === 'secret';
};
@@ -8,7 +11,8 @@ const importPostmanEnvironmentVariables = (brunoEnvironment, values) => {
each(values, (i) => {
const brunoEnvironmentVariable = {
name: i.key,
uid: uuid(),
name: i.key.replace(invalidVariableCharacterRegex, '_'),
value: i.value,
enabled: i.enabled,
secret: isSecret(i.type)

View File

@@ -2,6 +2,7 @@ import get from 'lodash/get';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
import each from 'lodash/each';
import postmanTranslation from './postman-translations';
import { invalidVariableCharacterRegex } from '../constants/index';
const parseGraphQLRequest = (graphqlSource) => {
try {
@@ -93,17 +94,10 @@ const importScriptsFromEvents = (events, requestObject) => {
requestObject.script = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
requestObject.script.req = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
requestObject.script.req = '';
}
} else if (typeof event.script.exec === 'string') {
requestObject.script.req = postmanTranslation(event.script.exec);
if (event.script.exec && event.script.exec.length > 0) {
requestObject.script.req = postmanTranslation(event.script.exec)
} else {
requestObject.script.req = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
@@ -113,17 +107,10 @@ const importScriptsFromEvents = (events, requestObject) => {
requestObject.tests = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
requestObject.tests = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
requestObject.tests = '';
}
} else if (typeof event.script.exec === 'string') {
requestObject.tests = postmanTranslation(event.script.exec);
if (event.script.exec && event.script.exec.length > 0) {
requestObject.tests = postmanTranslation(event.script.exec)
} else {
requestObject.tests = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
@@ -134,7 +121,7 @@ const importScriptsFromEvents = (events, requestObject) => {
const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.map((v) => ({
uid: uuid(),
name: v.key,
name: v.key.replace(invalidVariableCharacterRegex, '_'),
value: v.value,
enabled: true
}));
@@ -246,13 +233,13 @@ const processAuth = (auth, requestObject) => {
}
};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, { useWorkers = false } = {}, scriptMap)=> {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
const requestMap = {};
const requestMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE']
each(item, (i) => {
item.forEach((i, index) => {
if (isItemAFolder(i)) {
const baseFolderName = i.name || 'Untitled Folder';
let folderName = baseFolderName;
@@ -268,6 +255,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
name: folderName,
type: 'folder',
items: [],
seq: index + 1,
root: {
docs: i.description || '',
meta: {
@@ -291,6 +279,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
}
};
brunoParent.items.push(brunoFolderItem);
// Folder level auth
if (i.auth) {
processAuth(i.auth, brunoFolderItem.root.request);
@@ -300,221 +290,221 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth) => {
}
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth);
importPostmanV2CollectionItem(brunoFolderItem, i.item, i.auth ?? parentAuth, { useWorkers }, scriptMap);
}
if (i.event) {
importScriptsFromEvents(i.event, brunoFolderItem.root.request);
if(useWorkers) {
scriptMap.set(brunoFolderItem.uid, {
events: i.event,
request: brunoFolderItem.root.request
});
} else {
importScriptsFromEvents(i.event, brunoFolderItem.root.request);
}
}
brunoParent.items.push(brunoFolderItem);
folderMap[folderName] = brunoFolderItem;
} else {
if (i.request) {
if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
console.warn('Unexpected request.method', i?.request?.method);
return;
} else if (i.request) {
if (!requestMethods.includes(i?.request?.method.toUpperCase())) {
console.warn('Unexpected request.method', i?.request?.method);
return;
}
const baseRequestName = i.name || 'Untitled Request';
let requestName = baseRequestName;
let count = 1;
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
seq: index + 1,
request: {
url: url,
method: i?.request?.method?.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
docs: i.request.description || ''
}
};
const baseRequestName = i.name || 'Untitled Request';
let requestName = baseRequestName;
let count = 1;
brunoParent.items.push(brunoRequestItem);
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
request: {
url: url,
method: i?.request?.method?.toUpperCase(),
auth: {
mode: 'none',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth2: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
docs: i.request.description || ''
}
};
if (i.event) {
if (i.event) {
if(useWorkers) {
scriptMap.set(brunoRequestItem.uid, {
events: i.event,
request: brunoRequestItem.request
});
} else {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request.script) {
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
brunoRequestItem.request.script.req = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
brunoRequestItem.request.script.req = '';
}
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.script.req = postmanTranslation(event.script.exec);
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.script.req = postmanTranslation(event.script.exec)
} else {
brunoRequestItem.request.script.req = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request.tests) {
if (!brunoRequestItem.request?.tests) {
brunoRequestItem.request.tests = {};
}
if (Array.isArray(event.script.exec)) {
if (event.script.exec.length > 0) {
brunoRequestItem.request.tests = event.script.exec
.map((line) => postmanTranslation(line))
.join('\n');
} else {
brunoRequestItem.request.tests = '';
}
} else if (typeof event.script.exec === 'string') {
brunoRequestItem.request.tests = postmanTranslation(event.script.exec);
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.tests = postmanTranslation(event.script.exec)
} else {
brunoRequestItem.request.tests = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
const isFile = param.type === 'file';
let value;
let type;
if (isFile) {
// If param.src is an array, keep it as it is.
// If param.src is a string, convert it into an array with a single element.
value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
type = 'file';
} else {
value = param.value;
type = 'text';
}
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: type,
name: param.key,
value: value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
// Handle request-level auth or inherit from parent
const auth = i.request.auth ?? parentAuth;
processAuth(auth, brunoRequestItem.request);
each(get(i, 'request.url.query'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
description: param.description ?? '',
type: 'path',
enabled: true
});
});
brunoParent.items.push(brunoRequestItem);
requestMap[requestName] = brunoRequestItem;
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
const isFile = param.type === 'file';
let value;
let type;
if (isFile) {
// If param.src is an array, keep it as it is.
// If param.src is a string, convert it into an array with a single element.
value = Array.isArray(param.src) ? param.src : typeof param.src === 'string' ? [param.src] : null;
type = 'file';
} else {
value = param.value;
type = 'text';
}
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: type,
name: param.key,
value: value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
enabled: !param.disabled
});
});
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key,
value: header.value,
description: header.description,
enabled: !header.disabled
});
});
// Handle request-level auth or inherit from parent
const auth = i.request.auth ?? parentAuth;
processAuth(auth, brunoRequestItem.request);
each(get(i, 'request.url.query'), (param) => {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value,
description: param.description,
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
description: param.description ?? '',
type: 'path',
enabled: true
});
});
requestMap[requestName] = brunoRequestItem;
}
});
};
const searchLanguageByHeader = (headers) => {
let contentType;
each(headers, (header) => {
@@ -530,7 +520,7 @@ const searchLanguageByHeader = (headers) => {
return contentType;
};
const importPostmanV2Collection = (collection) => {
const importPostmanV2Collection = async (collection, { useWorkers = false }) => {
const brunoCollection = {
name: collection.info.name || 'Untitled Collection',
uid: uuid(),
@@ -571,12 +561,74 @@ const importPostmanV2Collection = (collection) => {
// Collection level auth
processAuth(collection.auth, brunoCollection.root.request);
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth);
// Create a single scriptMap for all items
const scriptMap = useWorkers ? new Map() : null;
importPostmanV2CollectionItem(brunoCollection, collection.item, collection.auth, { useWorkers }, scriptMap);
// Process all scripts in a single call at the top level
if (useWorkers && scriptMap && scriptMap.size > 0) {
try {
const { default: scriptTranslationWorker } = await import('../workers/postman-translator-worker');
const translatedScripts = await scriptTranslationWorker(scriptMap);
// Apply translated scripts to all items in the collection
const applyScriptsToItems = (items) => {
items.forEach(item => {
if (item.type === 'folder') {
// Apply scripts to the folder
if (translatedScripts.has(item.uid)) {
if (!item.root.request.script) {
item.root.request.script = {};
}
if (!item.root.request.tests) {
item.root.request.tests = '';
}
const script = translatedScripts.get(item.uid).request?.script?.req;
const tests = translatedScripts.get(item.uid).request?.tests;
item.root.request.script.req = script && script.length > 0 ? script : '';
item.root.request.tests = tests && tests.length > 0 ? tests : '';
}
// Recursively apply to nested items
if (item.items && item.items.length > 0) {
applyScriptsToItems(item.items);
}
} else {
if (translatedScripts.has(item.uid)) {
if (!item.request.script) {
item.request.script = {};
}
if (!item.request.tests) {
item.request.tests = '';
}
const script = translatedScripts.get(item.uid).request?.script?.req;
const tests = translatedScripts.get(item.uid).request?.tests;
item.request.script.req = script && script.length > 0 ? script : '';
item.request.tests = tests && tests.length > 0 ? tests : '';
}
}
});
};
applyScriptsToItems(brunoCollection.items);
} catch (error) {
console.error('Error in script translation worker:', error);
} finally {
scriptMap.clear();
}
}
return brunoCollection;
};
const parsePostmanCollection = (collection) => {
const parsePostmanCollection = async (collection, { useWorkers = false }) => {
try {
let schema = get(collection, 'info.schema');
@@ -588,7 +640,7 @@ const parsePostmanCollection = (collection) => {
];
if (v2Schemas.includes(schema)) {
return importPostmanV2Collection(collection);
return await importPostmanV2Collection(collection, { useWorkers });
}
throw new Error('Unsupported Postman schema version. Only Postman Collection v2.0 and v2.1 are supported.');
@@ -602,9 +654,10 @@ const parsePostmanCollection = (collection) => {
}
};
const postmanToBruno = (postmanCollection) => {
const postmanToBruno = async (postmanCollection, { useWorkers = false } = {}) => {
try {
const parsedPostmanCollection = parsePostmanCollection(postmanCollection);
const parsedPostmanCollection = await parsePostmanCollection(postmanCollection, { useWorkers });
const transformedCollection = transformItemsInCollection(parsedPostmanCollection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
const validatedCollection = validateSchema(hydratedCollection);
@@ -615,4 +668,5 @@ const postmanToBruno = (postmanCollection) => {
}
};
export default postmanToBruno;

View File

@@ -1,3 +1,5 @@
import translateCode from '../utils/jscode-shift-translator';
const replacements = {
'pm\\.environment\\.get\\(': 'bru.getEnvVar(',
'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
@@ -20,7 +22,7 @@ const replacements = {
'pm\\.response\\.responseTime': 'res.getResponseTime()',
'pm\\.environment\\.name': 'bru.getEnvName()',
'pm\\.response\\.status': 'res.statusText',
'pm\\.response\\.headers': 'req.getHeaders()',
'pm\\.response\\.headers': 'res.getHeaders()',
"tests\\['([^']+)'\\]\\s*=\\s*([^;]+);": 'test("$1", function() { expect(Boolean($2)).to.be.true; });',
'pm\\.request\\.url': 'req.getUrl()',
'pm\\.request\\.method': 'req.getMethod()',
@@ -48,22 +50,38 @@ const compiledReplacements = Object.entries(extendedReplacements).map(([pattern,
replacement
}));
const postmanTranslation = (script) => {
const processRegexReplacement = (code) => {
for (const { regex, replacement } of compiledReplacements) {
if (regex.test(code)) {
code = code.replace(regex, replacement);
}
}
if ((code.includes('pm.') || code.includes('postman.'))) {
code = code.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
}
return code;
}
const postmanTranslation = (script, options = {}) => {
let modifiedScript = Array.isArray(script) ? script.join('\n') : script;
try {
let modifiedScript = script;
let modified = false;
for (const { regex, replacement } of compiledReplacements) {
if (regex.test(modifiedScript)) {
modifiedScript = modifiedScript.replace(regex, replacement);
modified = true;
}
let translatedCode = translateCode(modifiedScript);
if ((translatedCode.includes('pm.') || translatedCode.includes('postman.'))) {
translatedCode = translatedCode.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
}
if (modifiedScript.includes('pm.') || modifiedScript.includes('postman.')) {
modifiedScript = modifiedScript.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
}
return modifiedScript;
return translatedCode;
} catch (e) {
return script;
console.warn('Error in postman translation:', e);
try {
return processRegexReplacement(modifiedScript);
} catch (e) {
console.warn('Error in postman translation:', e);
return modifiedScript;
}
}
};

View File

@@ -0,0 +1,680 @@
const j = require('jscodeshift');
const cloneDeep = require('lodash/cloneDeep');
/**
* Efficiently builds a string representation of a member expression without using toSource()
*
* @param {Object} node - The member expression node from the AST
* @return {string} - String representation of the member expression (e.g., "pm.environment.get")
*/
function getMemberExpressionString(node) {
// Handle base case: if this is an Identifier
if (node.type === 'Identifier') {
return node.name;
}
// Handle member expressions
if (node.type === 'MemberExpression') {
const objectStr = getMemberExpressionString(node.object);
// For computed properties like obj[prop], we need special handling
if (node.computed) {
// For literals like obj["prop"], we can include them in the string
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
return `${objectStr}.${node.property.value}`;
}
// For other computed properties, we can't reliably represent them as a simple string
return `${objectStr}.[computed]`;
}
// For regular property access like obj.prop
if (node.property.type === 'Identifier') {
return `${objectStr}.${node.property.name}`;
}
}
return '[unsupported]';
}
// Simple 1:1 translations for straightforward replacements
const simpleTranslations = {
// Environment variables
'pm.environment.get': 'bru.getEnvVar',
'pm.environment.set': 'bru.setEnvVar',
'pm.environment.name': 'bru.getEnvName()',
'pm.environment.unset': 'bru.deleteEnvVar',
// Variables
'pm.variables.get': 'bru.getVar',
'pm.variables.set': 'bru.setVar',
'pm.variables.has': 'bru.hasVar',
// Collection variables
'pm.collectionVariables.get': 'bru.getVar',
'pm.collectionVariables.set': 'bru.setVar',
'pm.collectionVariables.has': 'bru.hasVar',
'pm.collectionVariables.unset': 'bru.deleteVar',
// Request flow control
'pm.setNextRequest': 'bru.setNextRequest',
// Testing
'pm.test': 'test',
'pm.expect': 'expect',
'pm.expect.fail': 'expect.fail',
// Request properties
'pm.request.url': 'req.getUrl()',
'pm.request.method': 'req.getMethod()',
'pm.request.headers': 'req.getHeaders()',
'pm.request.body': 'req.getBody()',
// Response properties
'pm.response.json': 'res.getBody',
'pm.response.code': 'res.getStatus()',
'pm.response.status': 'res.statusText',
'pm.response.responseTime': 'res.getResponseTime()',
'pm.response.statusText': 'res.statusText',
'pm.response.headers': 'res.getHeaders()',
// Execution control
'pm.execution.skipRequest': 'bru.runner.skipRequest',
// Legacy Postman API (deprecated) (we can use pm instead of postman, as we are converting all postman references to pm in the code as the part of pre-processing)
'pm.setEnvironmentVariable': 'bru.setEnvVar',
'pm.getEnvironmentVariable': 'bru.getEnvVar',
'pm.clearEnvironmentVariable': 'bru.deleteEnvVar',
};
/* Complex transformations that need custom handling
* Note: Transform functions can return either a single node or an array of nodes.
* When returning an array of nodes, each node in the array will be inserted
* as a separate statement, which allows a single Postman expression to be
* transformed into multiple Bruno statements (e.g. for complex assertions).
*/
const complexTransformations = [
// pm.environment.has requires special handling
{
pattern: 'pm.environment.has',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// Create: bru.getEnvVar(arg) !== undefined && bru.getEnvVar(arg) !== null
return j.logicalExpression(
'&&',
j.binaryExpression(
'!==',
j.callExpression(j.identifier('bru.getEnvVar'), args),
j.identifier('undefined')
),
j.binaryExpression(
'!==',
j.callExpression(j.identifier('bru.getEnvVar'), args),
j.identifier('null')
)
);
}
},
{
pattern: 'pm.response.text',
transform: (_, j) => {
return j.callExpression(j.identifier('JSON.stringify'), [j.identifier('res.getBody()')]);
}
},
// Handle pm.response.to.have.status
{
pattern: 'pm.response.to.have.status',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// Create: expect(res.getStatus()).to.equal(arg)
return j.callExpression(
j.memberExpression(
j.callExpression(
j.identifier('expect'),
[
j.callExpression(
j.identifier('res.getStatus'),
[]
)
]
),
j.identifier('to.equal')
),
args
);
}
},
// handle 'pm.response.to.have.header' to expect(res.getHeaders()).to.have.property(args)
{
pattern: 'pm.response.to.have.header',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
if (args.length > 0) {
// Apply toLowerCase() to the first argument
args[0] = j.callExpression(
j.memberExpression(
args[0],
j.identifier('toLowerCase')
),
[]
);
}
// Create: expect(res.getHeaders()).to.have.property(args)
return j.callExpression(
j.memberExpression(
j.callExpression(
j.identifier('expect'),
[
j.callExpression(
j.identifier('res.getHeaders'),
[]
)
]
),
j.identifier('to.have.property')
),
args
);
}
},
// Handle pm.execution.setNextRequest(null)
{
pattern: 'pm.execution.setNextRequest',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// If argument is null or 'null', transform to bru.runner.stopExecution()
if (
args[0].type === 'Literal' && (args[0].value === null || args[0].value === 'null')
) {
return j.callExpression(
j.identifier('bru.runner.stopExecution'),
[]
);
}
// Otherwise, keep as bru.runner.setNextRequest with the same argument
return j.callExpression(
j.identifier('bru.runner.setNextRequest'),
args
);
}
},
];
// Create a map for complex transformations to enable O(1) lookups
const complexTransformationsMap = {};
complexTransformations.forEach(transform => {
complexTransformationsMap[transform.pattern] = transform;
});
const varInitsToReplace = new Set(['pm', 'postman', 'pm.request','pm.response', 'pm.test', 'pm.expect', 'pm.environment', 'pm.variables', 'pm.collectionVariables', 'pm.execution']);
/**
* Process all transformations (both simple and complex) in the AST in a single pass
* @param {Object} ast - jscodeshift AST
* @param {Set} transformedNodes - Set of already transformed nodes
*/
function processTransformations(ast, transformedNodes) {
ast.find(j.MemberExpression).forEach(path => {
if (transformedNodes.has(path.node)) return;
// Get string representation using our utility function
const memberExprStr = getMemberExpressionString(path.value);
// First check for simple transformations (O(1))
if (simpleTranslations.hasOwnProperty(memberExprStr)) {
const replacement = simpleTranslations[memberExprStr];
j(path).replaceWith(j.identifier(replacement));
transformedNodes.add(path.node);
return; // Skip complex transformation check if simple transformation applied
}
// Then check for complex transformations (O(1))
if (complexTransformationsMap.hasOwnProperty(memberExprStr) &&
path.parent.value.type === 'CallExpression') {
const transform = complexTransformationsMap[memberExprStr];
const replacement = transform.transform(path, j);
if (Array.isArray(replacement)) {
replacement.forEach((nodePath, index) => {
if(index === 0) {
j(path.parent).replaceWith(nodePath);
} else {
j(path.parent.parent).insertAfter(nodePath);
}
transformedNodes.add(nodePath.node);
transformedNodes.add(path.parent.node);
});
} else {
j(path.parent).replaceWith(replacement);
transformedNodes.add(path.node);
transformedNodes.add(path.parent.node);
}
}
});
}
/**
* Translates Postman script code to Bruno script code
* @param {string} code - The Postman script code to translate
* @returns {string} The translated Bruno script code
*/
function translateCode(code) {
// Replace 'postman' with 'pm' using regex before creating the AST
// This is more efficient than an AST traversal
code = code.replace(/\bpostman\b/g, 'pm');
const ast = j(code);
// Keep track of transformed nodes to avoid double-processing
const transformedNodes = new Set();
// Preprocess the code to resolve all aliases
preprocessAliases(ast);
// Process all transformations in a single pass
processTransformations(ast, transformedNodes);
// Handle special Postman syntax patterns
handleTestsBracketNotation(ast);
return ast.toSource();
}
/**
* Preprocess all variable aliases in the AST to simplify later transformations
* @param {Object} ast - jscodeshift AST
*/
function preprocessAliases(ast) {
// Create a symbol table to track what each variable references
const symbolTable = new Map();
const MAX_ITERATIONS = 5;
let iterations = 0;
// Keep preprocessing until no more changes can be made
let changesMade;
do {
changesMade = false;
// First pass: Identify all variables
findVariableDefinitions(ast, symbolTable);
// Second pass: Replace all variable references with their resolved values
changesMade = resolveVariableReferences(ast, symbolTable) || false;
// Third pass: Clean up variable declarations that are no longer needed
changesMade = removeResolvedDeclarations(ast, symbolTable) || false;
iterations++;
} while (changesMade && iterations < MAX_ITERATIONS);
}
/**
* Find all variable definitions and track what they reference
* @param {Object} ast - jscodeshift AST
* @param {Map} symbolTable - Map to track variable references
*/
function findVariableDefinitions(ast, symbolTable) {
// Use a single traversal to handle both direct assignments and object destructuring
ast.find(j.VariableDeclarator).forEach(path => {
// Only process nodes that have an initializer
if (!path.value.init) return;
// Handle direct assignments: const response = pm.response
if (path.value.id.type === 'Identifier') {
const varName = path.value.id.name;
// If it's a direct identifier, just map it
if (path.value.init.type === 'Identifier') {
symbolTable.set(varName, {
type: 'identifier',
value: path.value.init.name
});
}
// If it's a member expression, store both parts
else if (path.value.init.type === 'MemberExpression') {
const sourceCode = getMemberExpressionString(path.value.init);
symbolTable.set(varName, {
type: 'memberExpression',
value: sourceCode,
node: path.value.init
});
}
}
// Handle object destructuring: const { response } = pm
else if (path.value.id.type === 'ObjectPattern' && path.value.init.type === 'Identifier') {
const source = path.value.init.name;
path.value.id.properties.forEach(prop => {
if (prop.key.name && prop.value.type === 'Identifier') {
const destVarName = prop.value.name;
symbolTable.set(destVarName, {
type: 'memberExpression',
value: `${source}.${prop.key.name}`,
node: j.memberExpression(
j.identifier(source),
j.identifier(prop.key.name)
)
});
}
});
}
});
}
/**
* Resolve variable references by replacing them with their original values
* @param {Object} ast - jscodeshift AST
* @param {Map} symbolTable - Map of variable references
* @returns {boolean} Whether any changes were made
*/
function resolveVariableReferences(ast, symbolTable) {
let changesMade = false;
/**
* Example of what this function does:
*
* Input Postman code:
* const response = pm.response;
* const jsonData = response.json(); // response is a reference to pm.response
*
* After resolution:
* const response = pm.response;
* const jsonData = pm.response.json(); // response reference is replaced with pm.response
*
* Then in the next preprocessing phase, unnecessary variables like 'response' will be removed.
*/
// Replace all identifier references with their resolved values
ast.find(j.Identifier).forEach(path => {
const varName = path.value.name;
/**
* Skip specific types of identifiers that shouldn't be replaced:
*
* Case 1: Variable definitions (left side of declarations)
* -----------------------------------------------------
* In code like:
* const response = pm.response;
* ^
* We shouldn't replace 'response' on the left side with pm.response,
* which would result in: const pm.response = pm.response; (invalid syntax)
*
* Case 2: Property names in member expressions
* -----------------------------------------------------
* In code like:
* console.log(response.status);
* ^
* We shouldn't replace the 'status' property name with anything,
* only the 'response' object reference should be replaced.
*
* We only want to replace identifiers that are being used as references,
* not the ones being defined or used as property names.
*/
// Skip if this is a variable definition or property name
if (path.parent.value.type === 'VariableDeclarator' && path.parent.value.id === path.value) {
return;
}
if (path.parent.value.type === 'MemberExpression' && path.parent.value.property === path.value && !path.parent.value.computed) {
return;
}
// Only replace if this is a known variable
if (!symbolTable.has(varName)) return;
const symbolInfo = symbolTable.get(varName);
if(!varInitsToReplace.has(symbolInfo.value)) {
return;
}
const newNode = cloneDeep(symbolInfo.node);
j(path).replaceWith(newNode);
symbolTable.set(varName, {
type: 'memberExpression',
value: symbolInfo.value,
node: newNode
});
changesMade = true;
});
return changesMade;
}
/**
* Remove variable declarations that have been resolved
* @param {Object} ast - jscodeshift AST
* @param {Map} symbolTable - Map of variable references
* @returns {boolean} Whether any changes were made
*/
function removeResolvedDeclarations(ast, symbolTable) {
let changesMade = false;
/**
* Example of what this function does:
*
* Original Postman code:
* const response = pm.response;
* const jsonData = response.json();
* console.log(jsonData.name);
*
* After variable resolution:
* const response = pm.response; // This declaration is now redundant
* const jsonData = pm.response.json(); // This value has been resolved
* console.log(jsonData.name); // This still references jsonData
*
* Final code after this cleanup step:
* const jsonData = pm.response.json(); // response variable declaration is removed
* console.log(jsonData.name); // jsonData is kept since it's still referenced
*
* We only remove declarations that:
* 1. Have been fully resolved (references to pm.* objects)
* 2. No longer provide any value (since all references were replaced with resolved values)
*/
// Use a single traversal to handle both regular variable declarations and destructuring
ast.find(j.VariableDeclarator).forEach(path => {
// Case 1: Handle regular variable declarations
if (path.value.id.type === 'Identifier') {
const varName = path.value.id.name;
const replacement = symbolTable.get(varName);
if(!replacement || !varInitsToReplace.has(replacement.value)) return;
/**
* This code differentiates between two types of variable declarations:
*
* Example 1: Single variable declaration
* -----------------------------------
* Input: const response = pm.response;
* Action: The entire statement can be removed
* Output: [statement removed]
*
* Example 2: Multiple variables in one declaration
* -----------------------------------
* Input: const response = pm.response, unrelated = 5;
* Action: Only remove the 'response' declarator, keep the others
* Output: const unrelated = 5;
*
* We need this distinction to ensure we don't accidentally remove
* unrelated variables that happen to be declared in the same statement.
*/
const declarationPath = j(path).closest(j.VariableDeclaration);
if (declarationPath.get().value.declarations.length === 1) {
declarationPath.remove();
} else {
// Otherwise just remove this declarator
j(path).remove();
}
changesMade = true;
}
// Case 2: Handle destructuring of pm
else if (path.value.id.type === 'ObjectPattern' &&
path.value.init &&
path.value.init.type === 'Identifier' &&
path.value.init.name === 'pm') {
/**
* Example of destructuring removal:
*
* Original Postman code:
* const { response, environment } = pm;
* console.log(response.json().name);
* console.log(environment.get("variable"));
*
* After variable resolution steps:
* const { response, environment } = pm; // This destructuring is now redundant
* console.log(pm.response.json().name); // 'response' references already replaced with pm.response
* console.log(pm.environment.get("variable")); // 'environment' references replaced
*
* Final code after this cleanup step:
* console.log(pm.response.json().name); // Destructuring declaration is completely removed
* console.log(pm.environment.get("variable"));
*
* This step specifically targets the Postman pattern of destructuring the pm object,
* which is common in Postman scripts but needs to be removed in the Bruno conversion.
*/
const declarationPath = j(path).closest(j.VariableDeclaration);
if (declarationPath.get().value.declarations.length === 1) {
declarationPath.remove();
} else {
j(path).remove();
}
changesMade = true;
}
});
return changesMade;
}
/**
* Handle Postman's tests["..."] = ... syntax
* @param {Object} ast - jscodeshift AST
*/
function handleTestsBracketNotation(ast) {
// Find the ExpressionStatement that contains the assignment
ast.find(j.ExpressionStatement, {
expression: {
type: 'AssignmentExpression',
left: {
type: 'MemberExpression',
object: { name: 'tests' },
computed: true,
property: {} // Accept any property type
}
}
}).forEach(path => {
// Get the assignment expression
const assignment = path.value.expression;
const left = assignment.left;
// Verify it's a valid tests[] expression
if (left.object.type === 'Identifier' &&
left.object.name === 'tests' &&
left.computed === true) {
const property = left.property;
const rightSide = assignment.right;
// Handle string literals
if (property.type === 'Literal' && typeof property.value === 'string') {
const testName = property.value;
// Replace with test() function call
j(path).replaceWith(
j.expressionStatement(
j.callExpression(
j.identifier('test'),
[
j.literal(testName),
j.functionExpression(
null,
[],
j.blockStatement([
j.expressionStatement(
j.memberExpression(
j.callExpression(
j.identifier('expect'),
[
j.callExpression(
j.identifier('Boolean'),
[rightSide]
)
]
),
j.identifier('to.be.true')
)
)
])
)
]
)
)
);
}
// Handle template literals
else if (property.type === 'TemplateLiteral') {
// Create a template literal with the same quasi and expressions
const templateLiteral = j.templateLiteral(
property.quasis,
property.expressions
);
// Replace with test() function call using template literal
j(path).replaceWith(
j.expressionStatement(
j.callExpression(
j.identifier('test'),
[
templateLiteral,
j.functionExpression(
null,
[],
j.blockStatement([
j.expressionStatement(
j.memberExpression(
j.callExpression(
j.identifier('expect'),
[
j.callExpression(
j.identifier('Boolean'),
[rightSide]
)
]
),
j.identifier('to.be.true')
)
)
])
)
]
)
)
);
}
}
});
}
export { getMemberExpressionString };
export default translateCode;

View File

@@ -0,0 +1,211 @@
const { Worker } = require('node:worker_threads');
const path = require('node:path');
const os = require('node:os');
function getMaxWorkers() {
return Math.max(os.availableParallelism(), 1)
}
class WorkerPool {
constructor(scriptPath, size) {
this.workers = [];
this.idle = [];
this.queue = [];
this.scriptPath = scriptPath;
this.size = size;
}
// Initialize the worker pool
initialize() {
for (let i = 0; i < this.size; i++) {
const worker = new Worker(this.scriptPath);
this.workers.push(worker);
this.idle.push(i);
}
}
// Run a task on a worker
runTask(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
if (this.idle.length > 0) {
this._runTaskOnWorker(this.idle.shift(), task);
} else {
this.queue.push(task);
}
});
}
// Run a task on a specific worker
_runTaskOnWorker(workerId, task) {
const worker = this.workers[workerId];
const messageHandler = (result) => {
// Cleanup listeners
worker.removeListener('message', messageHandler);
worker.removeListener('error', errorHandler);
// Mark worker as idle
this.idle.push(workerId);
// Process queue if tasks are waiting
if (this.queue.length > 0) {
this._runTaskOnWorker(workerId, this.queue.shift());
}
// Resolve the task
task.resolve(result);
};
const errorHandler = (err) => {
worker.removeListener('message', messageHandler);
worker.removeListener('error', errorHandler);
this.idle.push(workerId);
if (this.queue.length > 0) {
this._runTaskOnWorker(workerId, this.queue.shift());
}
task.reject(err);
};
worker.on('message', messageHandler);
worker.on('error', errorHandler);
worker.postMessage(task.data);
}
// Terminate all workers
terminate() {
for (const worker of this.workers) {
worker.terminate();
}
this.workers = [];
this.idle = [];
}
}
// Helper function to count lines in a script
function countScriptLines(script) {
if (!script) return 0;
return Array.isArray(script) ? script.length : script.split('\n').length;
}
// Calculate complexity of a script entry
function calculateScriptComplexity([uid, entry]) {
let totalLines = 0;
const { events } = entry
if (events && Array.isArray(events)) {
events.forEach(({ script }) => {
if (script && script.exec) {
totalLines += countScriptLines(script.exec);
}
});
}
return { uid, entry, complexity: totalLines || 1 }; // Minimum complexity of 1
}
// Create balanced batches based on script complexity
function createBalancedBatches(scriptEntries, workerCount) {
// Calculate complexity for each script
const scriptsWithComplexity = scriptEntries.map(calculateScriptComplexity);
// Sort scripts by complexity (descending)
scriptsWithComplexity.sort((a, b) => b.complexity - a.complexity);
// Initialize batches
const batches = Array.from({ length: workerCount }, () => ({
entries: [],
totalComplexity: 0
}));
// Algorithm: Greedy load balancing
// 1. Process scripts in descending order of complexity
// 2. Always assign each script to the batch with lowest current load
// 3. This minimizes the maximum workload across all workers
for (const { uid, entry, complexity } of scriptsWithComplexity) {
const batchWithLowestComplexity = batches.reduce(
(target, current) => current.totalComplexity < target.totalComplexity ? current : target
);
// Add the script to this batch
batchWithLowestComplexity.entries.push({uid, entry});
batchWithLowestComplexity.totalComplexity += complexity;
}
return batches.map(batch =>
batch.entries.map(({ uid, entry }) => [uid, entry])
).filter(batch => batch.length > 0);
}
const scriptTranslationWorker = async (scriptMap) => {
// Convert the Map to an array of entries
const scriptEntries = Array.from(scriptMap.entries());
const maxWorkers = getMaxWorkers();
// For very small collections, don't parallelize
if (scriptEntries.length <= 50) {
const workerPool = new WorkerPool(path.join(__dirname,'./src/workers/scripts/translate-postman-scripts.js'), 1);
workerPool.initialize();
try {
const translatedScripts = new Map();
const result = await workerPool.runTask({ scripts: scriptEntries });
if (result.error) {
console.error('Error in script translation worker:', result.error);
throw new Error(result.error);
}
result.forEach(([uid, { request }]) => {
translatedScripts.set(uid, { request });
});
return translatedScripts;
} finally {
workerPool.terminate();
}
}
const workerCount = Math.min(maxWorkers, 4);
// Create balanced batches based on script complexity
const batches = createBalancedBatches(scriptEntries, workerCount);
const translatedScripts = new Map();
// Create worker pool with optimal size
const workerPool = new WorkerPool(path.join(__dirname,'./src/workers/scripts/translate-postman-scripts.js'), workerCount);
workerPool.initialize();
// Process all batches in parallel using worker pool
const batchPromises = batches.map(batch => {
return workerPool.runTask({ scripts: batch })
.then(modScripts => {
modScripts.forEach(([name, { request }]) => {
translatedScripts.set(name, { request });
});
})
.catch(err => {
console.error('Error in script translation worker:', err);
throw new Error(err);
});
});
// Wait for all batches to complete
try {
await Promise.allSettled(batchPromises);
} finally {
// Clean up worker pool
workerPool.terminate();
}
return translatedScripts;
};
export default scriptTranslationWorker

View File

@@ -0,0 +1,44 @@
const { parentPort } = require('node:worker_threads');
const { postmanTranslation } = require('@usebruno/converters');
parentPort.on('message', (workerData) => {
try {
const { scripts } = workerData;
const modScripts = scripts.map(([uid, { events }]) => {
const requestObject = {
script: {},
tests: {}
}
if (events && Array.isArray(events)) {
events.forEach((event) => {
if(event?.script && event.script.exec) {
if(event.listen === 'prerequest') {
if(event.script.exec && event.script.exec.length > 0) {
requestObject.script.req = postmanTranslation(event.script.exec);
} else {
requestObject.script.req = '';
}
}
if(event.listen === 'test') {
if(event.script.exec && event.script.exec.length > 0) {
requestObject.tests = postmanTranslation(event.script.exec);
} else {
requestObject.tests = '';
}
}
}
});
}
return [uid, { request: requestObject }];
});
parentPort.postMessage(modScripts);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
});

View File

@@ -32,12 +32,14 @@ describe('postmanToBrunoEnvironment Function', () => {
value: 'value1',
enabled: true,
secret: false,
uid: "mockeduuidvalue123456",
},
{
name: 'var2',
value: 'value2',
enabled: false,
secret: true,
uid: "mockeduuidvalue123456",
},
],
};

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from '@jest/globals';
import postmanToBruno from '../../../src/postman/postman-to-bruno';
describe('Collection Authentication', () => {
it('should handle basic auth at collection level', () => {
it('should handle basic auth at collection level', async() => {
const postmanCollection = {
info: {
name: 'Collection level basic auth',
@@ -44,7 +44,7 @@ describe('Collection Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
// console.log('result', JSON.stringify(result, null, 2));
expect(result.root.request.auth).toEqual({
@@ -61,7 +61,7 @@ describe('Collection Authentication', () => {
});
});
it('should handle bearer token auth at collection level', () => {
it('should handle bearer token auth at collection level', async() => {
const postmanCollection = {
info: {
name: 'Collection level bearer token',
@@ -98,7 +98,7 @@ describe('Collection Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
// console.log('result', JSON.stringify(result, null, 2));
expect(result.root.request.auth).toEqual({
@@ -112,9 +112,9 @@ describe('Collection Authentication', () => {
oauth2: null,
digest: null
});
});
});
it('should handle API key auth at collection level', () => {
it('should handle API key auth at collection level', async() => {
const postmanCollection = {
info: {
name: 'Collection level api key',
@@ -156,7 +156,7 @@ describe('Collection Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.root.request.auth).toEqual({
mode: 'apikey',
@@ -173,7 +173,7 @@ describe('Collection Authentication', () => {
});
});
it('should handle digest auth at collection level', () => {
it('should handle digest auth at collection level', async() => {
const postmanCollection = {
info: {
name: 'Collection level digest auth',
@@ -220,7 +220,7 @@ describe('Collection Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.root.request.auth).toEqual({
mode: 'digest',

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from '@jest/globals';
import postmanToBruno from '../../../src/postman/postman-to-bruno';
describe('Folder Authentication', () => {
it('should handle basic auth at folder level', () => {
it('should handle basic auth at folder level', async() => {
const postmanCollection = {
info: {
name: 'Folder level basic auth',
@@ -49,7 +49,7 @@ describe('Folder Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].root.request.auth).toEqual({
mode: 'basic',
@@ -65,7 +65,7 @@ describe('Folder Authentication', () => {
});
});
it('should handle bearer token auth at folder level', () => {
it('should handle bearer token auth at folder level', async() => {
const postmanCollection = {
info: {
name: 'Folder level bearer token',
@@ -107,7 +107,7 @@ describe('Folder Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].root.request.auth).toEqual({
mode: 'bearer',
@@ -120,7 +120,7 @@ describe('Folder Authentication', () => {
});
});
it('should handle API key auth at folder level', () => {
it('should handle API key auth at folder level', async() => {
const postmanCollection = {
info: {
name: 'Folder level API key',
@@ -167,7 +167,7 @@ describe('Folder Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].root.request.auth).toEqual({
mode: 'apikey',
@@ -180,7 +180,7 @@ describe('Folder Authentication', () => {
});
});
it('should handle digest auth at folder level', () => {
it('should handle digest auth at folder level', async() => {
const postmanCollection = {
info: {
name: 'Folder level digest auth',
@@ -232,7 +232,7 @@ describe('Folder Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].root.request.auth).toEqual({
mode: 'digest',

View File

@@ -3,7 +3,7 @@ import postmanToBruno from '../../../src/postman/postman-to-bruno';
describe('postman-collection', () => {
it('should correctly import a valid Postman collection file', async () => {
const brunoCollection = postmanToBruno(postmanCollection);
const brunoCollection = await postmanToBruno(postmanCollection);
expect(brunoCollection).toMatchObject(expectedOutput);
});
});
@@ -73,92 +73,93 @@ const expectedOutput = {
"version": "1",
"items": [
{
"uid": "mockeduuidvalue123456",
"name": "folder",
"type": "folder",
"items": [
{
"uid": "mockeduuidvalue123456",
"name": "request",
"type": "http-request",
"request": {
"url": "https://usebruno.com",
"method": "GET",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 1
}
],
"root": {
"docs": "",
"meta": {
"name": "folder"
},
"request": {
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"script": {},
"tests": "",
"vars": {}
}
}
"uid": "mockeduuidvalue123456",
"name": "folder",
"type": "folder",
"seq": 1,
"items": [
{
"uid": "mockeduuidvalue123456",
"name": "request",
"type": "http-request",
"seq": 1,
"request": {
"url": "https://usebruno.com",
"method": "GET",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
}
}
],
"root": {
"docs": "",
"meta": {
"name": "folder"
},
"request": {
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"script": {},
"tests": "",
"vars": {}
}
}
},
{
"uid": "mockeduuidvalue123456",
"name": "request",
"type": "http-request",
"request": {
"url": "https://usebruno.com",
"method": "GET",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
"seq": 1
"uid": "mockeduuidvalue123456",
"name": "request",
"type": "http-request",
"seq": 2,
"request": {
"url": "https://usebruno.com",
"method": "GET",
"auth": {
"mode": "none",
"basic": null,
"bearer": null,
"awsv4": null,
"apikey": null,
"oauth2": null,
"digest": null
},
"headers": [],
"params": [],
"body": {
"mode": "none",
"json": null,
"text": null,
"xml": null,
"formUrlEncoded": [],
"multipartForm": []
},
"docs": ""
},
}
],
"environments": [],

View File

@@ -20,7 +20,7 @@ describe('postmanTranslations - response commands', () => {
const responseText = JSON.stringify(res.getBody());
const responseJson = res.getBody();
const responseStatus = res.statusText;
const responseHeaders = req.getHeaders();
const responseHeaders = res.getHeaders();
test('Status code is 200', function() {
expect(res.getStatus()).to.equal(200);

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from '@jest/globals';
import postmanToBruno from '../../../src/postman/postman-to-bruno';
describe('Request Authentication', () => {
it('should handle basic auth at request level', () => {
it('should handle basic auth at request level', async() => {
const postmanCollection = {
info: {
name: 'Request Auth Collection',
@@ -26,7 +26,7 @@ describe('Request Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].request.auth).toEqual({
mode: 'basic',
@@ -42,7 +42,7 @@ describe('Request Authentication', () => {
});
});
it('should inherit folder auth when request has no auth', () => {
it('should inherit folder auth when request has no auth', async() => {
const postmanCollection = {
info: {
name: 'Inherit Request Auth Collection',
@@ -68,7 +68,7 @@ describe('Request Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].items[0].request.auth).toEqual({
mode: 'bearer',
@@ -83,7 +83,7 @@ describe('Request Authentication', () => {
});
});
it('should override folder auth with request auth', () => {
it('should override folder auth with request auth', async() => {
const postmanCollection = {
info: {
name: 'Override Request Auth Collection',
@@ -116,7 +116,7 @@ describe('Request Authentication', () => {
]
};
const result = postmanToBruno(postmanCollection);
const result = await postmanToBruno(postmanCollection);
expect(result.items[0].items[0].request.auth).toEqual({
mode: 'bearer',

View File

@@ -17,7 +17,7 @@ describe('postmanTranslations - comment handling', () => {
test('should comment non-translated pm commands', () => {
const inputScript = "pm.test('random test', () => postman.variables.replaceIn('{{$guid}}'));";
const expectedOutput = "// test('random test', () => postman.variables.replaceIn('{{$guid}}'));";
const expectedOutput = "// test('random test', () => pm.variables.replaceIn('{{$guid}}'));";
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,418 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Combined API Features Translation', () => {
// Basic translation test
it('should translate code', () => {
const code = 'console.log("Hello, world!");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe(code);
});
// Preserving comments
it('should preserve comments', () => {
const code = '// This is a comment';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('// This is a comment');
});
it('should preserve comments inside functions', () => {
const code = `
function getUserDetails() {
// Get user details from API
const response = pm.response.json();
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
function getUserDetails() {
// Get user details from API
const response = res.getBody();
}
`);
});
it('should preserve comments inside if statements', () => {
const code = `
if (pm.response.code === 200) {
// Success
console.log("Success");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
if (res.getStatus() === 200) {
// Success
console.log("Success");
}
`);
});
it('should preserve multiline comments', () => {
const code = `
/*
This is a multiline comment
*/
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
/*
This is a multiline comment
*/
`);
});
it('should preserve comments inside for loops', () => {
const code = `
for (let i = 0; i < 10; i++) {
// Loop iteration
console.log(pm.response.json()[i]);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
for (let i = 0; i < 10; i++) {
// Loop iteration
console.log(res.getBody()[i]);
}
`);
});
// Multiple transformations in the same code block
it('should handle multiple translations in the same code block', () => {
const code = `
const token = pm.environment.get("authToken");
pm.test("Auth flow works", function() {
const response = pm.response.json();
pm.expect(response.authenticated).to.be.true;
pm.environment.set("userId", response.user.id);
pm.collectionVariables.set("sessionId", response.session.id);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).not.toContain('pm.test("Auth flow works", function() {');
expect(translatedCode).not.toContain('pm.expect(response.authenticated).to.be.true;');
expect(translatedCode).not.toContain('pm.environment.set("userId", response.user.id);');
expect(translatedCode).not.toContain('pm.collectionVariables.set("sessionId", response.session.id);');
expect(translatedCode).toContain('const token = bru.getEnvVar("authToken");');
expect(translatedCode).toContain('test("Auth flow works", function() {');
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
expect(translatedCode).toContain('bru.setVar("sessionId", response.session.id);');
});
// Nested expressions
it('should handle nested Postman API calls', () => {
const code = 'pm.environment.set("computed", pm.variables.get("base") + "-suffix");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
});
it('should handle more complex nested expressions', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
});
// Unrelated code
it('should leave unrelated code untouched', () => {
const code = `
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(code);
});
it('should handle Postman API calls within JavaScript methods', () => {
const code = `
const helpers = {
getAuthHeader: function() {
return "Bearer " + pm.environment.get("token");
}
};
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('return "Bearer " + bru.getEnvVar("token");');
});
it('should handle aliases with object destructuring', () => {
const code = `
const { environment, variables } = pm;
environment.set("token", "abc123");
variables.get("userId");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.setEnvVar("token", "abc123");
bru.getVar("userId");
`);
});
// Code context tests
it('should translate pm commands inside functions', () => {
const code = `
function getAuthHeader() {
return "Bearer " + pm.environment.get("token");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
function getAuthHeader() {
return "Bearer " + bru.getEnvVar("token");
}
`);
});
it('should translate pm commands inside if statements', () => {
const code = `
if (pm.response.code === 200) {
console.log("Success");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
if (res.getStatus() === 200) {
console.log("Success");
}
`);
});
it('should translate pm commands inside if statements', () => {
const code = `
const json = pm.response.json();
if (json.code === 200) {
console.log("Success");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const json = res.getBody();
if (json.code === 200) {
console.log("Success");
}
`);
});
it('should translate pm commands inside else statements', () => {
const code = `
if (pm.response.code === 200) {
console.log("Success");
pm.response.to.have.status(200);
} else {
console.log("Failure");
expect(res.getStatus()).to.equal(400);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
if (res.getStatus() === 200) {
console.log("Success");
expect(res.getStatus()).to.equal(200);
} else {
console.log("Failure");
expect(res.getStatus()).to.equal(400);
}
`);
});
it('should translate pm commands inside for loops', () => {
const code = `
for (let i = 0; i < pm.response.json().length; i++) {
console.log(pm.response.json()[i]);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
for (let i = 0; i < res.getBody().length; i++) {
console.log(res.getBody()[i]);
}
`);
});
it('should translate pm commands inside while loops', () => {
const code = `
while (pm.response.code === 200) {
console.log("Success");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
while (res.getStatus() === 200) {
console.log("Success");
}
`);
});
it('should translate pm commands inside switch statements', () => {
const code = `
switch (pm.response.code) {
case 200:
console.log("Success");
break;
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
switch (res.getStatus()) {
case 200:
console.log("Success");
break;
}
`);
});
it('should translate pm commands inside try catch statements', () => {
const code = `
try {
pm.response.to.have.status(200);
} catch (error) {
console.log("Failure");
expect(res.getStatus()).to.equal(400);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
try {
expect(res.getStatus()).to.equal(200);
} catch (error) {
console.log("Failure");
expect(res.getStatus()).to.equal(400);
}
`);
});
it('should translate aliases within if statements block', () => {
const code = `
const env = pm.environment;
const vars = pm.variables;
const collVars = pm.collectionVariables;
const test = pm.test;
const expect = pm.expect;
const response = pm.response;
function processResponse() {
if(response.code === 200) {
console.log("Success");
} else if(response.code === 400) {
console.log("Failure");
expect(response.code).to.equal(400);
} else {
console.log("Unknown status code");
expect(response.code).to.equal(500);
}
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
function processResponse() {
if(res.getStatus() === 200) {
console.log("Success");
} else if(res.getStatus() === 400) {
console.log("Failure");
expect(res.getStatus()).to.equal(400);
} else {
console.log("Unknown status code");
expect(res.getStatus()).to.equal(500);
}
}
`);
});
it('should handle pm aliases inside functions', () => {
const code = `
const tempRes = pm.response;
const tempTest = pm.test;
const tempExpect = pm.expect;
const tempEnv = pm.environment;
const tempVars = pm.variables;
const tempCollVars = pm.collectionVariables;
function processResponse() {
tempTest("Status code is 200", function() { expect(tempRes.code).to.equal(200); });
tempEnv.set("userId", tempRes.json().userId);
tempVars.set("token", tempRes.json().token);
tempCollVars.set("sessionId", tempRes.json().sessionId);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
function processResponse() {
test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });
bru.setEnvVar("userId", res.getBody().userId);
bru.setVar("token", res.getBody().token);
bru.setVar("sessionId", res.getBody().sessionId);
}
`);
});
it('should nested pm commands', () => {
const code = `
pm.collectionVariables.get(pm.environment.get('key'))
pm.test("Status code is 200", function() {
pm.response.to.have.status(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.getVar(bru.getEnvVar('key'))
test("Status code is 200", function() {
expect(res.getStatus()).to.equal(200);
});
`);
});
it('should handle pm objects in template literals', () => {
const code = `
const baseUrl = pm.environment.get("baseUrl");
const endpoint = pm.variables.get("endpoint");
const url = \`\${baseUrl}/api/\${endpoint}\`;
console.log(\`Response status: \${pm.response.code}\`);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
expect(translatedCode).toContain('const endpoint = bru.getVar("endpoint");');
expect(translatedCode).toContain('const url = `${baseUrl}/api/${endpoint}`;');
expect(translatedCode).toContain('console.log(`Response status: ${res.getStatus()}`);');
});
it('should handle pm objects in arrow functions', () => {
const code = `
const getAuthHeader = () => "Bearer " + pm.environment.get("token");
const processItems = items => items.forEach(item => {
pm.variables.set(item.key, item.value);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const getAuthHeader = () => "Bearer " + bru.getEnvVar("token");');
expect(translatedCode).toContain('const processItems = items => items.forEach(item => {');
expect(translatedCode).toContain('bru.setVar(item.key, item.value);');
});
it('test', () => {
const code = `
const globals = pm.globals;
const key = globals.get("key");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const globals = pm.globals;
const key = globals.get("key");
`);
})
});

View File

@@ -0,0 +1,242 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Environment Variable Translation', () => {
it('should translate pm.environment.get', () => {
const code = 'pm.environment.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getEnvVar("test");');
});
it('should translate pm.environment.set', () => {
const code = 'pm.environment.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("test", "value");');
});
it('should translate pm.environment.has', () => {
const code = 'pm.environment.has("test")';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getEnvVar("test") !== undefined && bru.getEnvVar("test") !== null');
});
it('should translate pm.environment.unset', () => {
const code = 'pm.environment.unset("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.deleteEnvVar("test");');
});
it('should translate pm.environment.name', () => {
const code = 'pm.environment.name;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getEnvName();');
});
it('should handle nested Postman API calls with environment', () => {
const code = 'pm.environment.set("computed", pm.variables.get("base") + "-suffix");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
});
it('should handle JSON operations with environment variables', () => {
const code = 'pm.environment.set("user", JSON.stringify({ id: 123, name: "John" }));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("user", JSON.stringify({ id: 123, name: "John" }));');
});
it('should handle JSON.parse with environment variables', () => {
const code = 'const userData = JSON.parse(pm.environment.get("user"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const userData = JSON.parse(bru.getEnvVar("user"));');
});
it('should translate pm.environment.name with different access patterns', () => {
const code = `
const envName1 = pm.environment.name;
const env = pm.environment;
const envName2 = env.name;
console.log(pm.environment.name);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const envName1 = bru.getEnvName();
const envName2 = bru.getEnvName();
console.log(bru.getEnvName());
`);
});
it('should handle environment aliases', () => {
const code = `
const env = pm.environment;
const name = env.name;
const has = env.has("test");
const set = env.set("test", "value");
const get = env.get("test");
const unset = env.unset("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const name = bru.getEnvName();
const has = bru.getEnvVar("test") !== undefined && bru.getEnvVar("test") !== null;
const set = bru.setEnvVar("test", "value");
const get = bru.getEnvVar("test");
const unset = bru.deleteEnvVar("test");
`);
});
// Legacy API (postman.) tests related to environment
it('should translate postman.setEnvironmentVariable', () => {
const code = 'postman.setEnvironmentVariable("apiKey", "abc123");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("apiKey", "abc123");');
});
it('should translate postman.getEnvironmentVariable', () => {
const code = 'const baseUrl = postman.getEnvironmentVariable("baseUrl");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const baseUrl = bru.getEnvVar("baseUrl");');
});
it('should translate postman.clearEnvironmentVariable', () => {
const code = 'postman.clearEnvironmentVariable("tempToken");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.deleteEnvVar("tempToken");');
});
it('should handle all environment variable methods together', () => {
const code = `
// All environment variable methods
const envName = pm.environment.name;
const hasToken = pm.environment.has("token");
const token = pm.environment.get("token");
pm.environment.set("timestamp", new Date().toISOString());
console.log(\`Environment: \${envName}, Has token: \${hasToken}, Token: \${token}\`);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const envName = bru.getEnvName();');
expect(translatedCode).toContain('const hasToken = bru.getEnvVar("token") !== undefined && bru.getEnvVar("token") !== null;');
expect(translatedCode).toContain('const token = bru.getEnvVar("token");');
expect(translatedCode).toContain('bru.setEnvVar("timestamp", new Date().toISOString());');
});
// Additional robust tests for environment variables
it('should handle environment variables with computed property names', () => {
const code = `
const prefix = "api";
const suffix = "Key";
pm.environment.set(prefix + "_" + suffix, "abc123");
const computedValue = pm.environment.get(prefix + "_" + suffix);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setEnvVar(prefix + "_" + suffix, "abc123");');
expect(translatedCode).toContain('const computedValue = bru.getEnvVar(prefix + "_" + suffix);');
});
it('should handle environment variables in complex object structures', () => {
const code = `
const config = {
baseUrl: pm.environment.get("apiUrl"),
headers: {
"Authorization": "Bearer " + pm.environment.get("token"),
"X-Api-Key": pm.environment.get("apiKey") || "default-key"
},
timeout: parseInt(pm.environment.get("timeout") || "5000"),
validate: pm.environment.has("validateResponses")
};
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('baseUrl: bru.getEnvVar("apiUrl"),');
expect(translatedCode).toContain('"Authorization": "Bearer " + bru.getEnvVar("token"),');
expect(translatedCode).toContain('"X-Api-Key": bru.getEnvVar("apiKey") || "default-key"');
expect(translatedCode).toContain('timeout: parseInt(bru.getEnvVar("timeout") || "5000"),');
expect(translatedCode).toContain('validate: bru.getEnvVar("validateResponses") !== undefined && bru.getEnvVar("validateResponses") !== null');
});
it('should handle environment variables in conditionals correctly', () => {
const code = `
if (pm.environment.has("apiKey")) {
if (pm.environment.get("apiKey").length > 0) {
console.log("Valid API key exists");
} else {
console.log("API key is empty");
}
} else {
console.log("No API key defined");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null) {');
expect(translatedCode).toContain('if (bru.getEnvVar("apiKey").length > 0) {');
});
it('should handle multiple levels of environment variable aliasing', () => {
const code = `
const env = pm.environment;
env.set("key", "value");
const value = env.get("key");
const exists = env.has("key");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
bru.setEnvVar("key", "value");
const value = bru.getEnvVar("key");
const exists = bru.getEnvVar("key") !== undefined && bru.getEnvVar("key") !== null;
`);
});
it('should handle environment variables with dynamic values', () => {
const code = `
// Generate a timestamp for this request
const timestamp = new Date().toISOString();
pm.environment.set("requestTimestamp", timestamp);
// Generate a unique ID
const uniqueId = "req_" + Math.random().toString(36).substring(2, 15);
pm.environment.set("requestId", uniqueId);
// Calculate an expiry time (30 minutes from now)
const expiryTime = new Date();
expiryTime.setMinutes(expiryTime.getMinutes() + 30);
pm.environment.set("tokenExpiry", expiryTime.getTime());
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setEnvVar("requestTimestamp", timestamp);');
expect(translatedCode).toContain('bru.setEnvVar("requestId", uniqueId);');
expect(translatedCode).toContain('bru.setEnvVar("tokenExpiry", expiryTime.getTime());');
});
it('should handle environment variables in try-catch blocks', () => {
const code = `
try {
const configStr = pm.environment.get("config");
const config = JSON.parse(configStr);
console.log("Config loaded:", config.version);
} catch (error) {
console.error("Failed to parse config");
pm.environment.set("configError", error.message);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const configStr = bru.getEnvVar("config");');
expect(translatedCode).toContain('bru.setEnvVar("configError", error.message);');
});
it('should handle legacy environment and pm.setEnvironmentVariable together', () => {
const code = `
// Legacy style
postman.setEnvironmentVariable("legacyKey", "legacyValue");
// Mixed with newer style
const value = pm.environment.get("anotherKey");
// Another legacy form
pm.setEnvironmentVariable("thirdKey", "thirdValue");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setEnvVar("legacyKey", "legacyValue");');
expect(translatedCode).toContain('const value = bru.getEnvVar("anotherKey");');
expect(translatedCode).toContain('bru.setEnvVar("thirdKey", "thirdValue");');
});
});

View File

@@ -0,0 +1,64 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Execution Flow Translation', () => {
// Request flow control
it('should translate pm.setNextRequest', () => {
const code = 'pm.setNextRequest("Get User Details");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setNextRequest("Get User Details");');
});
it('should translate pm.execution.skipRequest', () => {
const code = 'if (condition) pm.execution.skipRequest();';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('if (condition) bru.runner.skipRequest();');
});
it('should translate pm.execution.setNextRequest(null)', () => {
const code = 'pm.execution.setNextRequest(null);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.runner.stopExecution();');
});
it('should translate pm.execution.setNextRequest("null")', () => {
const code = 'pm.execution.setNextRequest("null");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.runner.stopExecution();');
});
it('should handle pm.execution.setNextRequest with non-null parameters', () => {
const code = `
// Continue normal flow
pm.execution.setNextRequest("Get user details");
// With variable
const nextReq = "Update profile";
pm.execution.setNextRequest(nextReq);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.runner.setNextRequest("Get user details");');
expect(translatedCode).toContain('bru.runner.setNextRequest(nextReq);');
});
it('should handle all execution control methods together', () => {
const code = `
// All execution control methods
if (pm.response.code === 401) {
pm.execution.skipRequest();
} else if (pm.response.code === 500) {
pm.execution.setNextRequest(null);
} else {
pm.setNextRequest("Get User Details");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('if (res.getStatus() === 401) {');
expect(translatedCode).toContain('bru.runner.skipRequest();');
expect(translatedCode).toContain('} else if (res.getStatus() === 500) {');
expect(translatedCode).toContain('bru.runner.stopExecution();');
expect(translatedCode).toContain('} else {');
expect(translatedCode).toContain('bru.setNextRequest("Get User Details");');
});
});

View File

@@ -0,0 +1,283 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Legacy Tests[] Syntax Translation', () => {
it('should handle tests[] commands', () => {
const code = `
tests["Status code is 200"] = pm.response.code === 200;`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Status code is 200", function() {
expect(Boolean(res.getStatus() === 200)).to.be.true;
});`);
});
it('should handle tests[] with complex expressions', () => {
const code = `
tests["Response has valid data"] = pm.response.json().data && pm.response.json().data.length > 0;`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Response has valid data", function() {
expect(Boolean(res.getBody().data && res.getBody().data.length > 0)).to.be.true;
});`);
});
it('should handle tests[] with string equality', () => {
const code = `
tests["Content-Type is application/json"] = pm.response.headers.get("Content-Type") === "application/json";`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Content-Type is application/json", function() {
expect(Boolean(res.getHeaders().get("Content-Type") === "application/json")).to.be.true;
});`);
});
it('should handle tests[] with function calls', () => {
const code = `
tests["Response time is acceptable"] = pm.response.responseTime < 500;`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Response time is acceptable", function() {
expect(Boolean(res.getResponseTime() < 500)).to.be.true;
});`);
});
it('should handle tests[] with variable references', () => {
const code = `
const expectedStatus = 201;
tests["Status code is correct"] = pm.response.code === expectedStatus;`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const expectedStatus = 201;
test("Status code is correct", function() {
expect(Boolean(res.getStatus() === expectedStatus)).to.be.true;
});`);
});
it('should handle multiple tests[] statements', () => {
const code = `
tests["Status code is 200"] = pm.response.code === 200;
tests["Response has data"] = pm.response.json().hasOwnProperty("data");`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Status code is 200", function() {
expect(Boolean(res.getStatus() === 200)).to.be.true;
});
test("Response has data", function() {
expect(Boolean(res.getBody().hasOwnProperty("data"))).to.be.true;
});`);
});
it('should handle tests[] with special characters in name', () => {
const code = `
tests["Special characters: !@#$%^&*()"] = true;`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Special characters: !@#$%^&*()", function() {
expect(Boolean(true)).to.be.true;
});`);
});
it('should handle tests[] with pm.environment variables', () => {
const code = `
tests["Response matches environment variable"] = pm.response.json().id === pm.environment.get("expectedId");`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Response matches environment variable", function() {
expect(Boolean(res.getBody().id === bru.getEnvVar("expectedId"))).to.be.true;
});`);
});
it('should handle nested pm objects in tests[] assignments', () => {
const code = `
tests["Authentication header is present"] = pm.request.headers.has("Authorization");
tests["Data count is correct"] = pm.response.json().items.length === pm.variables.get("expectedCount");
`;
const translatedCode = translateCode(code);
// The exact translation might vary depending on implementation details,
// but we can check for key transformations
expect(translatedCode).toContain('test("Authentication header is present"');
expect(translatedCode).toContain('test("Data count is correct"');
expect(translatedCode).toContain('res.getBody().items.length === bru.getVar("expectedCount")');
});
// Additional robust tests for legacy tests[] syntax
it('should handle tests[] with complex boolean expressions', () => {
const code = `
tests["Complex validation"] = (pm.response.code >= 200 && pm.response.code < 300) ||
(pm.response.json().success === true && pm.response.json().data !== null);`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Complex validation", function() {');
expect(translatedCode).toContain('expect(Boolean((res.getStatus() >= 200 && res.getStatus() < 300) ||');
expect(translatedCode).toContain('(res.getBody().success === true && res.getBody().data !== null))).to.be.true;');
});
it('should handle tests[] with array methods', () => {
const code = `
tests["All items have an ID"] = pm.response.json().items.every(item => item.hasOwnProperty('id'));
tests["Has premium item"] = pm.response.json().items.some(item => item.type === 'premium');`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("All items have an ID", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getBody().items.every(item => item.hasOwnProperty(\'id\')))).to.be.true;');
expect(translatedCode).toContain('test("Has premium item", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getBody().items.some(item => item.type === \'premium\'))).to.be.true;');
});
it('should handle tests[] with template literals in the name', () => {
const code = `
const endpoint = "users";
tests[\`Endpoint \${endpoint} returns valid response\`] = pm.response.code === 200;`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const endpoint = "users";');
expect(translatedCode).toContain('test(`Endpoint ${endpoint} returns valid response`, function() {');
expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
});
it('should handle tests[] with deep property access', () => {
const code = `
tests["User has admin role"] = pm.response.json().user &&
pm.response.json().user.roles &&
pm.response.json().user.roles.includes('admin');`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("User has admin role", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getBody().user &&');
expect(translatedCode).toContain('res.getBody().user.roles &&');
expect(translatedCode).toContain('res.getBody().user.roles.includes(\'admin\'))).to.be.true;');
});
it('should handle tests[] with JSON schema validation patterns', () => {
const code = `
const schema = {
type: "object",
required: ["id", "name"],
properties: {
id: { type: "string" },
name: { type: "string" }
}
};
const data = pm.response.json();
// Basic schema validation patterns
tests["Has required fields"] = data.hasOwnProperty('id') && data.hasOwnProperty('name');
tests["ID is string"] = typeof data.id === 'string';
tests["Name is string"] = typeof data.name === 'string';`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const schema = {');
expect(translatedCode).toContain('type: "object",');
expect(translatedCode).toContain('required: ["id", "name"],');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('test("Has required fields", function() {');
expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'id\') && data.hasOwnProperty(\'name\'))).to.be.true;');
expect(translatedCode).toContain('test("ID is string", function() {');
expect(translatedCode).toContain('expect(Boolean(typeof data.id === \'string\')).to.be.true;');
});
it('should handle tests[] within conditional blocks', () => {
const code = `
const data = pm.response.json();
if (pm.response.code === 200) {
tests["Success response has data"] = data.hasOwnProperty('items');
if (data.items.length > 0) {
tests["First item has ID"] = data.items[0].hasOwnProperty('id');
}
} else {
tests["Error response has message"] = data.hasOwnProperty('message');
}`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('if (res.getStatus() === 200) {');
expect(translatedCode).toContain('test("Success response has data", function() {');
expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'items\'))).to.be.true;');
expect(translatedCode).toContain('if (data.items.length > 0) {');
expect(translatedCode).toContain('test("First item has ID", function() {');
expect(translatedCode).toContain('expect(Boolean(data.items[0].hasOwnProperty(\'id\'))).to.be.true;');
expect(translatedCode).toContain('} else {');
expect(translatedCode).toContain('test("Error response has message", function() {');
expect(translatedCode).toContain('expect(Boolean(data.hasOwnProperty(\'message\'))).to.be.true;');
});
it('should handle tests[] with combination of legacy and modern styles', () => {
const code = `
// Legacy style
tests["Status code is 200"] = pm.response.code === 200;
// Modern style
pm.test("Response has valid data", function() {
const json = pm.response.json();
pm.expect(json).to.be.an('object');
pm.expect(json.items).to.be.an('array');
// Mix by using tests[] inside pm.test
tests["All items have price"] = json.items.every(item => item.hasOwnProperty('price'));
});`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Status code is 200", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
expect(translatedCode).toContain('test("Response has valid data", function() {');
expect(translatedCode).toContain('const json = res.getBody();');
expect(translatedCode).toContain('expect(json).to.be.an(\'object\');');
expect(translatedCode).toContain('expect(json.items).to.be.an(\'array\');');
expect(translatedCode).toContain('test("All items have price", function() {');
expect(translatedCode).toContain('expect(Boolean(json.items.every(item => item.hasOwnProperty(\'price\')))).to.be.true;');
});
it('should handle complex real-world tests[] example', () => {
const code = `
// Parse response
const response = pm.response.json();
// Basic response validation
tests["Status code is 200"] = pm.response.code === 200;
tests["Response is valid JSON"] = response !== null && typeof response === 'object';
// Check headers
tests["Has content-type header"] = pm.response.headers.has("Content-Type");
tests["Content-Type is JSON"] = pm.response.headers.get("Content-Type").includes("application/json");
// Validate against expected values
const expectedItems = parseInt(pm.environment.get("expectedItemCount"));
tests["Has correct number of items"] = response.items.length === expectedItems;
// Check for required fields on all items
const requiredFields = ["id", "name", "price", "category"];
tests["All items have required fields"] = response.items.every(item => {
return requiredFields.every(field => item.hasOwnProperty(field));
});
// Validate specific business rules
tests["No items with zero price"] = response.items.every(item => parseFloat(item.price) > 0);
tests["Has at least one featured item"] = response.items.some(item => item.featured === true);
// If we find a specific item we're looking for, save its ID for later
const targetItem = response.items.find(item => item.name === pm.variables.get("targetItemName"));
if (targetItem) {
pm.environment.set("targetItemId", targetItem.id);
tests["Found target item"] = true;
}`;
const translatedCode = translateCode(code);
// Check key transformations
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('test("Status code is 200", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getStatus() === 200)).to.be.true;');
expect(translatedCode).toContain('test("Has content-type header", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getHeaders().has("Content-Type"))).to.be.true;');
expect(translatedCode).toContain('test("Content-Type is JSON", function() {');
expect(translatedCode).toContain('expect(Boolean(res.getHeaders().get("Content-Type").includes("application/json"))).to.be.true;');
expect(translatedCode).toContain('const expectedItems = parseInt(bru.getEnvVar("expectedItemCount"));');
expect(translatedCode).toContain('test("Has correct number of items", function() {');
expect(translatedCode).toContain('expect(Boolean(response.items.length === expectedItems)).to.be.true;');
expect(translatedCode).toContain('const targetItem = response.items.find(item => item.name === bru.getVar("targetItemName"));');
expect(translatedCode).toContain('bru.setEnvVar("targetItemId", targetItem.id);');
});
});

View File

@@ -0,0 +1,283 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Multiline Syntax Handling', () => {
it('should handle basic multiline variable syntax with indentation', () => {
const code = `
const userId = pm.variables
.get("userId");
pm.variables
.set("timestamp", new Date().toISOString());
const hasToken = pm.variables
.has("token");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const userId = bru.getVar("userId");
bru.setVar("timestamp", new Date().toISOString());
const hasToken = bru.hasVar("token");
`);
});
it('should handle multiline environment variable syntax', () => {
const code = `
const baseUrl = pm
.environment
.get("baseUrl");
pm
.environment
.set("requestTime", Date.now());
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const baseUrl = bru.getEnvVar("baseUrl");
bru.setEnvVar("requestTime", Date.now());
`);
});
it('should handle multiline collection variable syntax', () => {
const code = `
const apiKey = pm.collectionVariables
.get("apiKey");
pm.collectionVariables
.set("lastRun", new Date().toISOString());
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const apiKey = bru.getVar("apiKey");
bru.setVar("lastRun", new Date().toISOString());
`);
});
it('should handle complex environment.has transformation with multiline syntax', () => {
const code = `
if (pm.environment
.has("apiKey")) {
console.log("API Key exists");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null) {
console.log("API Key exists");
}
`);
});
it('should handle response.to.have.status with multiline formatting', () => {
const code = `
pm.test("Status code is correct", function() {
pm
.response
.to
.have
.status(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
});
it('should handle response.to.have.header with multiline formatting', () => {
const code = `
pm.test("Content type is present", function() {
pm
.response
.to
.have
.header("content-type");
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("content-type".toLowerCase())');
});
it('should handle response properties with multiline syntax', () => {
const code = `
const responseBody = pm
.response
.json();
const responseText = pm
.response
.text;
const responseTime = pm
.response
.responseTime;
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const responseBody = res.getBody()');
expect(translatedCode).toContain('const responseText = ');
expect(translatedCode).toContain('const responseTime = res.getResponseTime()');
});
it('should handle execution flow control with multiline syntax', () => {
const code = `
// Stop execution
pm
.execution
.setNextRequest(null);
// Continue to next request
pm
.execution
.setNextRequest("Next API Call");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('// Stop execution');
expect(translatedCode).toContain('// Continue to next request');
expect(translatedCode).toContain('bru.runner.stopExecution()');
expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
});
it('should handle mixed normal and multiline syntax in the same code', () => {
const code = `
// Normal syntax
const normalVar = pm.variables.get("normal");
// Multiline syntax
const multilineVar = pm.variables
.get("multiline");
// Normal syntax again
pm.variables.set("normalSet", "value");
// Multiline syntax again
pm.variables
.set("multilineSet", "value");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
// Normal syntax
const normalVar = bru.getVar("normal");
// Multiline syntax
const multilineVar = bru.getVar("multiline");
// Normal syntax again
bru.setVar("normalSet", "value");
// Multiline syntax again
bru.setVar("multilineSet", "value");
`);
});
it('should handle complex multiline method chaining', () => {
const code = `
pm
.test("Test with chaining", function() {
pm
.response
.to
.have
.status(200);
const body = pm
.response
.json();
pm
.expect(body)
.to
.have
.property('success')
.equal(true);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Test with chaining", function() {');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
expect(translatedCode).toContain('const body = res.getBody()');
expect(translatedCode).toContain('.property(\'success\')');
expect(translatedCode).toContain('.equal(true)');
});
it('should handle a comprehensive script with various multiline formats', () => {
const code = `
// This comprehensive script tests different multiline styles and whitespace variations
// Environment variables with different formatting styles
const baseUrl = pm.environment.get("baseUrl");
const apiKey = pm
.environment
.get("apiKey");
const userId = pm.environment
.get("userId");
// Mix of variable styles
pm.variables.set("testId", "test-" + Date.now());
pm
.variables
.set("timestamp", new Date().toISOString());
// Collection variables with inconsistent spacing
pm.collectionVariables
.set("lastRun", new Date());
// Complex conditionals with multiline expressions
if (pm
.environment
.has("apiKey") &&
pm.variables.has("testId")) {
// Testing response with mixed syntax styles
pm.test("Response validation", function() {
// Normal style
pm.response.to.have.status(200);
// Multiline with different indentation
pm
.response
.to
.have
.header("content-type");
pm.response
.to.have
.jsonBody("success", true);
// Extreme indentation
pm
.response
.to
.not
.have
.jsonBody("error");
});
// Flow control with mixed styles
if (pm.response.code === 401) {
pm.execution.setNextRequest(null);
} else {
pm
.execution
.setNextRequest("Next API Call");
}
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl")');
expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey")');
expect(translatedCode).toContain('const userId = bru.getEnvVar("userId")');
// Check variables translations
expect(translatedCode).toContain('bru.setVar("testId", "test-" + Date.now())');
expect(translatedCode).toContain('bru.setVar("timestamp", new Date().toISOString())');
// Check collection variables
expect(translatedCode).toContain('bru.setVar("lastRun", new Date())');
// Check complex conditionals
expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null &&');
expect(translatedCode).toContain('bru.hasVar("testId"))');
// Check response testing
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200)');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("content-type".toLowerCase())');
// Check flow control
expect(translatedCode).toContain('if (res.getStatus() === 401)');
expect(translatedCode).toContain('bru.runner.stopExecution()');
expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
});
});

View File

@@ -0,0 +1,132 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Postman to PM References Conversion', () => {
// Basic conversions
it('should convert basic postman references to pm', () => {
const code = 'postman.setEnvironmentVariable("key", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setEnvVar("key", "value");');
// The key part is that it should convert postman.* to pm.* internally before
// translating to bru.* APIs
});
it('should convert postman variable access to pm', () => {
const code = 'const value = postman.getEnvironmentVariable("key");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const value = bru.getEnvVar("key");');
});
it('should handle postman variable assignments', () => {
const code = `
const envVar = postman.environment.get("apiKey");
const baseUrl = postman.environment.get("baseUrl");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const envVar = bru.getEnvVar("apiKey");');
expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
});
// More complex patterns
it('should handle mixed postman and pm references in the same code', () => {
const code = `
// Using both postman and pm APIs
const apiKey = postman.environment.get("apiKey");
const baseUrl = pm.environment.get("baseUrl");
// Using both formats in a test
postman.test("Status code is 200", function() {
pm.expect(pm.response.code).to.equal(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey");');
expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
expect(translatedCode).toContain('test("Status code is 200", function() {');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
});
it('should handle postman references in object destructuring', () => {
const code = `
const { environment } = postman;
environment.set("key", "value");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.setEnvVar("key", "value");');
});
// Complex control flows
it('should handle postman references in control flow statements', () => {
const code = `
if (postman.environment.get("isProduction") === "true") {
const apiUrl = postman.environment.get("prodUrl");
postman.setNextRequest("Production Flow");
} else {
const apiUrl = postman.environment.get("devUrl");
postman.setNextRequest("Development Flow");
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('if (bru.getEnvVar("isProduction") === "true") {');
expect(translatedCode).toContain('const apiUrl = bru.getEnvVar("prodUrl");');
expect(translatedCode).toContain('bru.setNextRequest("Production Flow");');
expect(translatedCode).toContain('const apiUrl = bru.getEnvVar("devUrl");');
expect(translatedCode).toContain('bru.setNextRequest("Development Flow");');
});
// Legacy response handling
it('should handle legacy postman response methods', () => {
const code = `
// Using legacy response handling
const responseCode = postman.response.code;
const responseBody = postman.response.json();
// Set environment variables with response data
postman.setEnvironmentVariable("lastResponseCode", responseCode);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const responseCode = res.getStatus();');
expect(translatedCode).toContain('const responseBody = res.getBody();');
expect(translatedCode).toContain('bru.setEnvVar("lastResponseCode", responseCode);');
});
// Postman in string literals should be untouched
it('should not convert postman references in string literals', () => {
const code = `
console.log("This is a pm script");
const message = "We're using pm to test our API";
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('console.log("This is a pm script");');
expect(translatedCode).toContain('const message = "We\'re using pm to test our API";');
});
// Complex example with aliasing
it('should handle complex postman reference patterns with aliasing', () => {
const code = `
// Aliasing the postman object
const env = postman.environment;
const code = postman.code;
// Using the alias
const apiKey = env.get("apiKey");
const userId = env.get("userId");
// Using alias in tests
postman.test("Response is valid", function() {
postman.expect(code).to.equal(200);
});
`;
const translatedCode = translateCode(code);
// Should handle the aliases properly
expect(translatedCode).toContain('const apiKey = bru.getEnvVar("apiKey");');
expect(translatedCode).toContain('const userId = bru.getEnvVar("userId");');
expect(translatedCode).toContain('test("Response is valid", function() {');
expect(translatedCode).toContain('expect(code).to.equal(200);');
});
});

View File

@@ -0,0 +1,108 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Request Translation', () => {
it('should translate pm.request.url', () => {
const code = 'const requestUrl = pm.request.url;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const requestUrl = req.getUrl();');
});
it('should translate pm.request.method', () => {
const code = 'const method = pm.request.method;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const method = req.getMethod();');
});
it('should translate pm.request.headers', () => {
const code = 'const headers = pm.request.headers;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const headers = req.getHeaders();');
});
it('should translate pm.request.body', () => {
const code = 'const body = pm.request.body;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const body = req.getBody();');
});
it('should translate pm.response.statusText', () => {
const code = 'const statusText = pm.response.statusText;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const statusText = res.statusText;');
});
it('should translate multiple request methods in one block', () => {
const code = `
const url = pm.request.url;
const method = pm.request.method;
const headers = pm.request.headers;
const body = pm.request.body;
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const url = req.getUrl();
const method = req.getMethod();
const headers = req.getHeaders();
const body = req.getBody();
`);
});
it('should handle request and response properties together', () => {
const code = `
// Get request data
const url = pm.request.url;
const method = pm.request.method;
// Get response data
const statusCode = pm.response.code;
const statusText = pm.response.statusText;
// Verify expectations
pm.test("Request was made correctly", function() {
pm.expect(method).to.equal("POST");
pm.expect(url).to.include("/api/items");
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const url = req.getUrl();');
expect(translatedCode).toContain('const method = req.getMethod();');
expect(translatedCode).toContain('const statusCode = res.getStatus();');
expect(translatedCode).toContain('const statusText = res.statusText;');
expect(translatedCode).toContain('test("Request was made correctly", function() {');
expect(translatedCode).toContain('expect(method).to.equal("POST");');
expect(translatedCode).toContain('expect(url).to.include("/api/items");');
});
it('should handle request properties in conditional blocks', () => {
const code = `
if (pm.request.method === "POST") {
console.log("This is a POST request to " + pm.request.url);
pm.test("Request has correct content-type", function() {
pm.expect(pm.request.headers.has("Content-Type")).to.be.true;
});
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('if (req.getMethod() === "POST") {');
expect(translatedCode).toContain('console.log("This is a POST request to " + req.getUrl());');
expect(translatedCode).toContain('test("Request has correct content-type", function() {');
// Note: The expectation for headers.has might be transformed differently
// depending on how complex transformations are handled
});
it('should handle request data extraction and variable setting', () => {
const code = `
// Extract request data
const requestData = pm.request.body;
const contentType = pm.request.headers.get("Content-Type");
// Save for later use
pm.variables.set("lastRequestBody", JSON.stringify(requestData));
pm.environment.set("lastContentType", contentType);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const requestData = req.getBody();');
expect(translatedCode).toContain('bru.setVar("lastRequestBody", JSON.stringify(requestData));');
expect(translatedCode).toContain('bru.setEnvVar("lastContentType", contentType);');
});
});

View File

@@ -0,0 +1,489 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Response Translation', () => {
// Basic response property tests
it('should translate pm.response.json', () => {
const code = 'const jsonData = pm.response.json();';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const jsonData = res.getBody();');
});
it('should translate pm.response.code', () => {
const code = 'if (pm.response.code === 200) { console.log("Success"); }';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('if (res.getStatus() === 200) { console.log("Success"); }');
});
it('should translate pm.response.text', () => {
const code = 'const responseText = pm.response.text();';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const responseText = JSON.stringify(res.getBody());');
});
it('should translate pm.response.responseTime', () => {
const code = 'console.log("Response time:", pm.response.responseTime);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('console.log("Response time:", res.getResponseTime());');
});
it('should translate pm.response.statusText', () => {
const code = 'console.log("Status text:", pm.response.statusText);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('console.log("Status text:", res.statusText);');
});
// Complex response transformations
it('should transform pm.response.to.have.status', () => {
const code = 'pm.response.to.have.status(201);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getStatus()).to.equal(201);');
});
it('should transform pm.response.to.have.header with single argument', () => {
const code = 'pm.response.to.have.header("Content-Type");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase());');
});
it('should transform multiple pm.response.to.have.header statements', () => {
const code = `
pm.response.to.have.header("Content-Type", "application/json");
pm.response.to.have.header("Cache-Control", "no-cache");
`;
const translatedCode = translateCode(code);
// Check for the existence of all four assertions (two pairs)
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Cache-Control".toLowerCase(), "no-cache");');
});
it('should transform pm.response.to.have.header inside control structures', () => {
const code = `
if (pm.response.code === 200) {
pm.response.to.have.header("Content-Type", "application/json");
}
`;
const translatedCode = translateCode(code);
// The assertions should be inside the if block
expect(translatedCode).toContain('if (res.getStatus() === 200) {');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
});
it('should transform pm.response.to.have.header with variable parameters', () => {
const code = `
const headerName = "Content-Type";
const expectedValue = "application/json";
pm.response.to.have.header(headerName, expectedValue);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const headerName = "Content-Type";');
expect(translatedCode).toContain('const expectedValue = "application/json";');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase(), expectedValue);');
});
// Response aliases tests
it('should handle response aliases', () => {
const code = `
const response = pm.response;
const status = response.status;
const body = response.json();
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const status = res.statusText;
const body = res.getBody();
`);
});
// Response to.have.status with different formats
it('should handle pm.response.to.have.status with different status codes', () => {
const code = `
// Test different status codes
pm.response.to.have.status(200); // OK
pm.response.to.have.status(201); // Created
pm.response.to.have.status(400); // Bad Request
pm.response.to.have.status(404); // Not Found
pm.response.to.have.status(500); // Server Error
// With variables
const expectedStatus = 200;
pm.response.to.have.status(expectedStatus);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(201);');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(400);');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(404);');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(500);');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(expectedStatus);');
});
// Alias for pm.response.to.have.status
it('should handle pm.response.to.have.status alias', () => {
const code = `
const resp = pm.response;
resp.to.have.status(200);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
expect(res.getStatus()).to.equal(200);
`);
});
it('should handle pm.response.to.have.header alias', () => {
const code = `
const resp = pm.response;
resp.to.have.header("Content-Type");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase());
`);
});
it('should handle pm.response.to.have.header alias with value check', () => {
const code = `
const resp = pm.response;
resp.to.have.header("Content-Type", "application/json");
`;
const translatedCode = translateCode(code);
// Check for both assertions when using an alias
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
});
it('should translate response.status', () => {
const code = `
const resp = pm.response;
const statusCode = resp.status;
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const statusCode = res.statusText;
`);
});
it('should translate response.body', () => {
const code = `
const resp = pm.response;
const responseBody = resp.json();
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const responseBody = res.getBody();
`);
});
it('should translate pm.response.statusText', () => {
const code = `
const resp = pm.response;
const statusText = resp.statusText;
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const statusText = res.statusText;
`);
});
it('should translate multiple response methods in one block', () => {
const code = `
const resp = pm.response;
const statusCode = resp.code;
const statusText = resp.statusText;
const jsonData = resp.json();
const responseText = resp.text();
const time = resp.responseTime;
resp.to.have.status(200);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const statusCode = res.getStatus();
const statusText = res.statusText;
const jsonData = res.getBody();
const responseText = JSON.stringify(res.getBody());
const time = res.getResponseTime();
expect(res.getStatus()).to.equal(200);
`);
});
it('should handle accessing nested properties on response objects', () => {
const code = `
const resp = pm.response;
const data = resp.json();
if (data && data.user && data.user.id) {
pm.environment.set("userId", data.user.id);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).not.toContain('const resp = pm.response;');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('bru.setEnvVar("userId", data.user.id);');
});
it('should handle all response property methods together', () => {
const code = `
// All response property methods
const statusCode = pm.response.code;
const responseBody = pm.response.json();
const responseText = pm.response.text();
const statusText = pm.response.statusText;
const responseTime = pm.response.responseTime;
pm.test("Response is valid", function() {
pm.response.to.have.status(200);
pm.expect(responseBody).to.be.an('object');
pm.expect(responseTime).to.be.below(1000);
pm.expect(statusText).to.equal('OK');
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const statusCode = res.getStatus();');
expect(translatedCode).toContain('const responseBody = res.getBody();');
expect(translatedCode).toContain('const responseText = JSON.stringify(res.getBody());');
expect(translatedCode).toContain('const responseTime = res.getResponseTime();');
expect(translatedCode).toContain('const statusText = res.statusText;');
expect(translatedCode).toContain('test("Response is valid", function() {');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
expect(translatedCode).toContain('expect(responseBody).to.be.an(\'object\');');
expect(translatedCode).toContain('expect(responseTime).to.be.below(1000);');
expect(translatedCode).toContain('expect(statusText).to.equal(\'OK\');');
});
it('should handle pm objects with array access on response', () => {
const code = `
const items = pm.response.json().items;
for (let i = 0; i < items.length; i++) {
pm.collectionVariables.set("item_" + i, items[i].id);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const items = res.getBody().items;');
expect(translatedCode).toContain('bru.setVar("item_" + i, items[i].id);');
});
it('should handle response JSON with optional chaining and nullish coalescing', () => {
const code = `
const userId = pm.response.json()?.user?.id ?? "anonymous";
const items = pm.response.json()?.data?.items || [];
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const userId = res.getBody()?.user?.id ?? "anonymous";');
expect(translatedCode).toContain('const items = res.getBody()?.data?.items || [];');
});
it('should handle response headers with different access patterns', () => {
// will need to handle get, set methods, bruno does not support this yet
const code = `
const contentType = pm.response.headers.get('Content-Type');
const contentLength = pm.response.headers.get('Content-Length');
console.log("contentType", contentType);
console.log("contentLength", contentLength);
pm.test("Headers are correct", function() {
pm.response.to.have.header('Content-Type');
pm.response.to.have.header('Content-Length');
pm.expect(contentType).to.include('application/json');
});
`;
const translatedCode = translateCode(code);
// Check how header access is translated
expect(translatedCode).toContain('const contentType = res.getHeaders().get(\'Content-Type\');');
expect(translatedCode).toContain('const contentLength = res.getHeaders().get(\'Content-Length\');');
expect(translatedCode).toContain('console.log("contentType", contentType);');
expect(translatedCode).toContain('console.log("contentLength", contentLength);');
expect(translatedCode).not.toContain('pm.test')
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(\'Content-Type\'.toLowerCase())');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(\'Content-Length\'.toLowerCase())');
expect(translatedCode).toContain('expect(contentType).to.include(\'application/json\')');
});
it('should transform response data with array destructuring', () => {
const code = `
const { id, name, items } = pm.response.json();
const [first, second] = items;
pm.environment.set("userId", id);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const { id, name, items } = res.getBody();');
expect(translatedCode).toContain('const [first, second] = items;');
expect(translatedCode).toContain('bru.setEnvVar("userId", id);');
});
it('should handle response in complex conditionals', () => {
const code = `
if (pm.response.code >= 200 && pm.response.code < 300) {
if (pm.response.headers.get('Content-Type').includes('application/json')) {
const data = pm.response.json();
if (data.success === true && data.token) {
pm.environment.set("authToken", data.token);
} else if (data.error) {
console.error("API error:", data.error);
}
}
} else if (pm.response.code === 404) {
console.log("Resource not found");
} else {
console.error("Request failed with status:", pm.response.code);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('if (res.getStatus() >= 200 && res.getStatus() < 300) {');
expect(translatedCode).toContain('if (res.getHeaders().get(\'Content-Type\').includes(\'application/json\')) {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('bru.setEnvVar("authToken", data.token);');
expect(translatedCode).toContain('} else if (res.getStatus() === 404) {');
expect(translatedCode).toContain('console.error("Request failed with status:", res.getStatus());');
});
it('should handle response processing with try-catch', () => {
const code = `
try {
const data = pm.response.json();
pm.environment.set("userData", JSON.stringify(data.user));
} catch (error) {
console.error("Failed to parse response:", error);
const text = pm.response.text();
pm.environment.set("rawResponse", text);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('bru.setEnvVar("userData", JSON.stringify(data.user));');
expect(translatedCode).toContain('const text = JSON.stringify(res.getBody());');
expect(translatedCode).toContain('bru.setEnvVar("rawResponse", text);');
});
it('should handle JSON path style access to response data', () => {
const code = `
const data = pm.response.json();
const userId = data.user.id;
const userEmail = data.user.contact.email;
const firstItem = data.items[0];
pm.environment.set("userId", userId);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('const userId = data.user.id;');
expect(translatedCode).toContain('const userEmail = data.user.contact.email;');
expect(translatedCode).toContain('const firstItem = data.items[0];');
expect(translatedCode).toContain('bru.setEnvVar("userId", userId);');
});
it('should handle template literals with response data', () => {
const code = `
const data = pm.response.json();
const welcomeMessage = \`Hello, \${data.user.name}! Your ID is \${data.user.id}.\`;
pm.environment.set("welcomeMessage", welcomeMessage);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('const welcomeMessage = `Hello, ${data.user.name}! Your ID is ${data.user.id}.`;');
expect(translatedCode).toContain('bru.setEnvVar("welcomeMessage", welcomeMessage);');
});
it('should handle response processing in arrow functions', () => {
const code = `
const processItems = () => {
const items = pm.response.json().items;
return items.map(item => item.id);
};
const itemIds = processItems();
pm.environment.set("itemIds", JSON.stringify(itemIds));
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const items = res.getBody().items;');
expect(translatedCode).toContain('return items.map(item => item.id);');
expect(translatedCode).toContain('const itemIds = processItems();');
expect(translatedCode).toContain('bru.setEnvVar("itemIds", JSON.stringify(itemIds));');
});
it('should handle complex inline operations with response data', () => {
const code = `
const items = pm.response.json().items;
const totalValue = items.reduce((sum, item) => sum + item.price, 0);
const highValueItems = items.filter(item => item.price > 100);
const itemNames = items.map(item => item.name);
pm.environment.set("totalValue", totalValue);
pm.environment.set("highValueItemCount", highValueItems.length);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const items = res.getBody().items;');
expect(translatedCode).toContain('const totalValue = items.reduce((sum, item) => sum + item.price, 0);');
expect(translatedCode).toContain('const highValueItems = items.filter(item => item.price > 100);');
expect(translatedCode).toContain('const itemNames = items.map(item => item.name);');
expect(translatedCode).toContain('bru.setEnvVar("totalValue", totalValue);');
expect(translatedCode).toContain('bru.setEnvVar("highValueItemCount", highValueItems.length);');
});
it('should handle complex test structure with pm.response.to.have.header', () => {
const code = `
pm.test("Response headers validation", function() {
pm.response.to.have.header("Content-Type", "application/json");
pm.response.to.have.header("Cache-Control");
const responseTime = pm.response.responseTime;
pm.expect(responseTime).to.be.below(1000);
});
`;
const translatedCode = translateCode(code);
// Check for test function conversion
expect(translatedCode).toContain('test("Response headers validation", function() {');
// Check for header assertions inside the test callback
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase(), "application/json");');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property("Cache-Control".toLowerCase())');
// Check that other test assertions are preserved
expect(translatedCode).toContain('const responseTime = res.getResponseTime();');
expect(translatedCode).toContain('expect(responseTime).to.be.below(1000);');
});
it('should handle dynamic header names in pm.response.to.have.header', () => {
const code = `
function checkHeaderPresent(headerName) {
pm.response.to.have.header(headerName);
}
function validateHeader(headerName, expectedValue) {
pm.response.to.have.header(headerName, expectedValue);
}
checkHeaderPresent("Authorization");
validateHeader("Content-Type", "application/json");
`;
const translatedCode = translateCode(code);
// Check function transformations
expect(translatedCode).toContain('function checkHeaderPresent(headerName) {');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase())');
expect(translatedCode).toContain('function validateHeader(headerName, expectedValue) {');
expect(translatedCode).toContain('expect(res.getHeaders()).to.have.property(headerName.toLowerCase(), expectedValue);');
// Check function calls
expect(translatedCode).toContain('checkHeaderPresent("Authorization");');
expect(translatedCode).toContain('validateHeader("Content-Type", "application/json");');
});
});

View File

@@ -0,0 +1,51 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Scoped Variables', () => {
it.skip('should handle scoped variables correctly', () => {
const code = `
const response = pm.response;
const status = response.status;
function test() {
const response = delta.response;
const status = response.status;
console.log(status);
}
`
const result = translateCode(code);
console.log(result);
expect(result).toBe(`
const status = res.statusText;
function test() {
const response = delta.response;
const status = response.status;
console.log(status);
}
`)
})
it.skip('should handle scoped variables correctly', () => {
const code = `
const response = delta.response;
const status = response.status;
function test() {
const response = pm.response;
const status = response.status;
console.log(status);
}
`
const result = translateCode(code);
console.log(result);
expect(result).toBe(`
const response = delta.response;
const status = response.status;
function test() {
const status = res.statusText;
console.log(status);
}
`)
})
})

View File

@@ -0,0 +1,399 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Testing Framework Translation', () => {
// Basic testing framework translations
it('should translate pm.test', () => {
const code = 'pm.test("Status code is 200", function() { pm.response.to.have.status(200); });';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });');
});
it('should translate pm.expect', () => {
const code = 'pm.expect(jsonData.success).to.be.true;';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('expect(jsonData.success).to.be.true;');
});
it('should translate pm.expect.fail', () => {
const code = 'if (!isValid) pm.expect.fail("Data is invalid");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('if (!isValid) expect.fail("Data is invalid");');
});
// Tests with response assertions
it('should translate pm.response.to.have.status in tests', () => {
const code = `
pm.test("Check environment and call successful", function () {
pm.expect(pm.environment.name).to.equal("ENVIRONMENT_NAME");
pm.response.to.have.status(200);
});`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
test("Check environment and call successful", function () {
expect(bru.getEnvName()).to.equal("ENVIRONMENT_NAME");
expect(res.getStatus()).to.equal(200);
});`);
});
// Test aliases
it('should handle test aliases', () => {
const code = `
const { test, expect } = pm;
test("Status code is 200", function () {
expect(pm.response.code).to.equal(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).not.toContain('const { test, expect } = pm');
expect(translatedCode).toContain('test("Status code is 200", function () {');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
});
// Tests inside different code structures
it('should translate pm commands inside tests with nested functions', () => {
const code = `
pm.test("Auth flow works", function() {
const response = pm.response.json();
pm.expect(response.authenticated).to.be.true;
pm.environment.set("userId", response.user.id);
pm.collectionVariables.set("sessionId", response.session.id);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Auth flow works", function() {');
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
expect(translatedCode).toContain('bru.setVar("sessionId", response.session.id);');
});
it('should translate pm.test with arrow functions', () => {
const code = `
pm.test("Status code is 200", () => {
pm.expect(pm.response.code).to.eql(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Status code is 200", () => {');
expect(translatedCode).toContain('expect(res.getStatus()).to.eql(200);');
});
it('should handle multiple test assertions in one function', () => {
const code = `
pm.test("The response has all properties", () => {
const responseJson = pm.response.json();
pm.expect(responseJson.type).to.eql('vip');
pm.expect(responseJson.name).to.be.a('string');
pm.expect(responseJson.id).to.have.lengthOf(1);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("The response has all properties", () => {');
expect(translatedCode).toContain('const responseJson = res.getBody();');
expect(translatedCode).toContain('expect(responseJson.type).to.eql(\'vip\');');
expect(translatedCode).toContain('expect(responseJson.name).to.be.a(\'string\');');
expect(translatedCode).toContain('expect(responseJson.id).to.have.lengthOf(1);');
});
// Test with aliased variables
it('should translate aliases within test functions', () => {
const code = `
const tempRes = pm.response;
const tempTest = pm.test;
const tempExpect = pm.expect;
tempTest("Status code is 200", function() {
tempExpect(tempRes.code).to.equal(200);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).not.toContain('const tempRes = pm.response;');
expect(translatedCode).not.toContain('const tempTest = pm.test;');
expect(translatedCode).not.toContain('const tempExpect = pm.expect;');
expect(translatedCode).toContain('test("Status code is 200", function() {');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
});
// Additional robust tests for testing framework
it('should handle nested test functions', () => {
const code = `
pm.test("Main test group", function() {
const responseJson = pm.response.json();
pm.test("User data validation", function() {
pm.expect(responseJson.user).to.be.an('object');
pm.expect(responseJson.user.id).to.be.a('string');
});
pm.test("Settings validation", function() {
pm.expect(responseJson.settings).to.be.an('object');
pm.expect(responseJson.settings.notifications).to.be.a('boolean');
});
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Main test group", function() {');
expect(translatedCode).toContain('const responseJson = res.getBody();');
expect(translatedCode).toContain('test("User data validation", function() {');
expect(translatedCode).toContain('expect(responseJson.user).to.be.an(\'object\');');
expect(translatedCode).toContain('test("Settings validation", function() {');
expect(translatedCode).toContain('expect(responseJson.settings.notifications).to.be.a(\'boolean\');');
});
it('should handle test with dynamic test names', () => {
const code = `
const endpoint = pm.variables.get("currentEndpoint");
pm.test(\`\${endpoint} returns correct data\`, function() {
const responseJson = pm.response.json();
pm.expect(responseJson).to.be.an('object');
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const endpoint = bru.getVar("currentEndpoint");');
expect(translatedCode).toContain('test(`${endpoint} returns correct data`, function() {');
expect(translatedCode).toContain('const responseJson = res.getBody();');
expect(translatedCode).toContain('expect(responseJson).to.be.an(\'object\');');
});
it('should handle test with conditional execution', () => {
const code = `
const responseJson = pm.response.json();
if (responseJson.type === 'user') {
pm.test("User validation", function() {
pm.expect(responseJson.name).to.be.a('string');
pm.expect(responseJson.email).to.be.a('string');
});
} else if (responseJson.type === 'admin') {
pm.test("Admin validation", function() {
pm.expect(responseJson.accessLevel).to.be.above(5);
pm.expect(responseJson.permissions).to.be.an('array');
});
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const responseJson = res.getBody();');
expect(translatedCode).toContain('if (responseJson.type === \'user\') {');
expect(translatedCode).toContain('test("User validation", function() {');
expect(translatedCode).toContain('expect(responseJson.name).to.be.a(\'string\');');
expect(translatedCode).toContain('} else if (responseJson.type === \'admin\') {');
expect(translatedCode).toContain('test("Admin validation", function() {');
expect(translatedCode).toContain('expect(responseJson.accessLevel).to.be.above(5);');
});
it('should handle assertions with logical operators', () => {
const code = `
pm.test("Response has valid structure", function() {
const data = pm.response.json();
pm.expect(data.id && data.name).to.be.ok;
pm.expect(data.active || data.pending).to.be.true;
pm.expect(!data.deleted).to.be.true;
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Response has valid structure", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('expect(data.id && data.name).to.be.ok;');
expect(translatedCode).toContain('expect(data.active || data.pending).to.be.true;');
expect(translatedCode).toContain('expect(!data.deleted).to.be.true;');
});
it('should handle array and object assertions', () => {
const code = `
pm.test("Array and object validations", function() {
const data = pm.response.json();
// Array validations
pm.expect(data.items).to.be.an('array');
pm.expect(data.items).to.have.lengthOf.at.least(1);
pm.expect(data.items[0]).to.have.property('id');
// Object validations
pm.expect(data.user).to.be.an('object');
pm.expect(data.user).to.have.all.keys('id', 'name', 'email');
pm.expect(data.user).to.include({active: true});
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Array and object validations", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('expect(data.items).to.be.an(\'array\');');
expect(translatedCode).toContain('expect(data.items).to.have.lengthOf.at.least(1);');
expect(translatedCode).toContain('expect(data.items[0]).to.have.property(\'id\');');
expect(translatedCode).toContain('expect(data.user).to.be.an(\'object\');');
expect(translatedCode).toContain('expect(data.user).to.have.all.keys(\'id\', \'name\', \'email\');');
expect(translatedCode).toContain('expect(data.user).to.include({active: true});');
});
it('should handle chai assertions with deep equality', () => {
const code = `
pm.test("Deep equality checks", function() {
const data = pm.response.json();
pm.expect(data.config).to.deep.equal({
version: "1.0",
active: true,
features: ["search", "export"]
});
pm.expect(data.tags).to.have.members(['api', 'test']);
pm.expect(data.meta).to.deep.include({format: 'json'});
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Deep equality checks", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('expect(data.config).to.deep.equal({');
expect(translatedCode).toContain('version: "1.0",');
expect(translatedCode).toContain('active: true,');
expect(translatedCode).toContain('features: ["search", "export"]');
expect(translatedCode).toContain('expect(data.tags).to.have.members([\'api\', \'test\']);');
expect(translatedCode).toContain('expect(data.meta).to.deep.include({format: \'json\'});');
});
it('should handle chai assertions with string comparisons', () => {
const code = `
pm.test("String validations", function() {
const data = pm.response.json();
pm.expect(data.id).to.be.a('string');
pm.expect(data.name).to.match(/^[A-Za-z\\s]+$/);
pm.expect(data.description).to.include('API');
pm.expect(data.url).to.have.string('api/v1');
pm.expect(data.code).to.have.lengthOf(8);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("String validations", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('expect(data.id).to.be.a(\'string\');');
expect(translatedCode).toContain('expect(data.name).to.match(/^[A-Za-z\\s]+$/);');
expect(translatedCode).toContain('expect(data.description).to.include(\'API\');');
expect(translatedCode).toContain('expect(data.url).to.have.string(\'api/v1\');');
expect(translatedCode).toContain('expect(data.code).to.have.lengthOf(8);');
});
it('should handle assertions with numeric comparisons', () => {
const code = `
pm.test("Numeric validations", function() {
const data = pm.response.json();
pm.expect(data.count).to.be.a('number');
pm.expect(data.count).to.be.above(0);
pm.expect(data.price).to.be.within(10, 100);
pm.expect(data.discount).to.be.at.most(25);
pm.expect(data.quantity * data.price).to.equal(data.total);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Numeric validations", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('expect(data.count).to.be.a(\'number\');');
expect(translatedCode).toContain('expect(data.count).to.be.above(0);');
expect(translatedCode).toContain('expect(data.price).to.be.within(10, 100);');
expect(translatedCode).toContain('expect(data.discount).to.be.at.most(25);');
expect(translatedCode).toContain('expect(data.quantity * data.price).to.equal(data.total);');
});
it('should handle pm.expect.fail with conditions', () => {
const code = `
pm.test("Validate critical fields", function() {
const data = pm.response.json();
if (!data.id) {
pm.expect.fail("Missing ID field");
}
if (data.status !== 'active' && data.status !== 'pending') {
pm.expect.fail("Invalid status: " + data.status);
}
// Continue with normal assertions
pm.expect(data.name).to.be.a('string');
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Validate critical fields", function() {');
expect(translatedCode).toContain('const data = res.getBody();');
expect(translatedCode).toContain('if (!data.id) {');
expect(translatedCode).toContain('expect.fail("Missing ID field");');
expect(translatedCode).toContain('if (data.status !== \'active\' && data.status !== \'pending\') {');
expect(translatedCode).toContain('expect.fail("Invalid status: " + data.status);');
expect(translatedCode).toContain('expect(data.name).to.be.a(\'string\');');
});
it('should handle complex test compositions', () => {
const code = `
// Helper function
function validateUserObject(user) {
pm.expect(user).to.be.an('object');
pm.expect(user.id).to.be.a('string');
pm.expect(user.name).to.be.a('string');
return user.id && user.name;
}
pm.test("Response validation", function() {
const response = pm.response.json();
const validUsers = [];
// Test status code
pm.response.to.have.status(200);
// Test main user
if (response.user) {
const isValid = validateUserObject(response.user);
if (isValid) {
validUsers.push(response.user);
}
}
// Test related users
if (response.relatedUsers && Array.isArray(response.relatedUsers)) {
pm.test("Related users validation", function() {
response.relatedUsers.forEach((user, index) => {
pm.test(\`User at index \${index}\`, function() {
const isValid = validateUserObject(user);
if (isValid) {
validUsers.push(user);
}
});
});
});
}
// Set the valid users for later use
if (validUsers.length > 0) {
pm.environment.set("validUsers", JSON.stringify(validUsers));
}
});
`;
const translatedCode = translateCode(code);
// Test key transformations
expect(translatedCode).toContain('function validateUserObject(user) {');
expect(translatedCode).toContain('expect(user).to.be.an(\'object\');');
expect(translatedCode).toContain('test("Response validation", function() {');
expect(translatedCode).toContain('const response = res.getBody();');
expect(translatedCode).toContain('expect(res.getStatus()).to.equal(200);');
expect(translatedCode).toContain('test("Related users validation", function() {');
expect(translatedCode).toContain('test(`User at index ${index}`, function() {');
expect(translatedCode).toContain('bru.setEnvVar("validUsers", JSON.stringify(validUsers));');
});
});

View File

@@ -0,0 +1,91 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Variable Chaining Resolution', () => {
test('should resolve a simple variable chain (variable pointing to another variable)', () => {
const code = `
const original = pm.response;
const alias = original;
const data = alias.json();
`;
const translatedCode = translateCode(code);
// Check that alias.json() was properly resolved to res.getBody()
expect(translatedCode).toContain('const data = res.getBody();');
// The original variable declarations should be removed
expect(translatedCode).not.toContain('const original =');
expect(translatedCode).not.toContain('const alias =');
});
test('should handle mixed variable references correctly', () => {
const code = `
const respVar = pm.response;
const envVar = pm.environment;
const respAlias = respVar;
// These should be replaced
const statusCode = respAlias.code;
const envValue = envVar.get("key");
// This should not be replaced
const unrelatedVar = "some value";
`;
const translatedCode = translateCode(code);
// Check correct replacements
expect(translatedCode).not.toContain('const respVar');
expect(translatedCode).not.toContain('const envVar');
expect(translatedCode).toContain('const statusCode = res.getStatus();');
expect(translatedCode).toContain('const envValue = bru.getEnvVar("key");');
// Check that unrelated variables are preserved
expect(translatedCode).toContain('const unrelatedVar = "some value";');
});
/**
* This test verifies that when multiple variables are declared in a single statement,
* only the ones referencing Postman objects are removed and the others are preserved.
*
* For example, in a statement like:
* const response = pm.response, counter = 5, helper = "test";
*
* Only 'response' should be removed, resulting in:
* const counter = 5, helper = "test";
*/
test('should handle multiple variables in one declaration statement', () => {
const code = `
// Multiple variables in one declaration, with a mix of Postman objects and regular variables
const response = pm.response, counter = 5, helper = "test";
// Using both the Postman reference (should be replaced) and regular values (should be preserved)
const statusCode = response.code;
console.log("Counter value:", counter);
console.log("Helper string:", helper);
// Another example with different Postman object
let env = pm.environment, timeout = 1000, isValid = true;
const baseUrl = env.get("baseUrl");
`;
const translatedCode = translateCode(code);
// Postman references should be replaced
expect(translatedCode).not.toContain('response = pm.response');
expect(translatedCode).not.toContain('env = pm.environment');
// Regular variables should be preserved
expect(translatedCode).toContain('const counter = 5');
expect(translatedCode).toContain('helper = "test"');
expect(translatedCode).toContain('timeout = 1000');
expect(translatedCode).toContain('isValid = true');
// References to Postman objects should be properly translated
expect(translatedCode).toContain('const statusCode = res.getStatus();');
expect(translatedCode).toContain('const baseUrl = bru.getEnvVar("baseUrl");');
// Console logs with regular variables should be preserved
expect(translatedCode).toContain('console.log("Counter value:", counter);');
expect(translatedCode).toContain('console.log("Helper string:", helper);');
});
});

View File

@@ -0,0 +1,128 @@
import translateCode from '../../../../src/utils/jscode-shift-translator';
describe('Variables Translation', () => {
// Regular variables tests
it('should translate pm.variables.get', () => {
const code = 'pm.variables.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("test");');
});
it('should translate pm.variables.set', () => {
const code = 'pm.variables.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("test", "value");');
});
it('should translate pm.variables.has', () => {
const code = 'pm.variables.has("userId");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("userId");');
});
// Collection variables tests
it('should translate pm.collectionVariables.get', () => {
const code = 'pm.collectionVariables.get("apiUrl");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("apiUrl");');
});
it('should translate pm.collectionVariables.set', () => {
const code = 'pm.collectionVariables.set("token", jsonData.token);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("token", jsonData.token);');
});
it('should translate pm.collectionVariables.has', () => {
const code = 'pm.collectionVariables.has("authToken");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("authToken");');
});
it('should translate pm.collectionVariables.unset', () => {
const code = 'pm.collectionVariables.unset("tempVar");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.deleteVar("tempVar");');
});
// Alias tests for variables
it('should handle variables aliases', () => {
const code = `
const vars = pm.variables;
const has = vars.has("test");
const set = vars.set("test", "value");
const get = vars.get("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
const get = bru.getVar("test");
`);
});
// Alias tests for collection variables
it('should handle collection variables aliases', () => {
const code = `
const collVars = pm.collectionVariables;
const has = collVars.has("test");
const set = collVars.set("test", "value");
const get = collVars.get("test");
const unset = collVars.unset("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
const get = bru.getVar("test");
const unset = bru.deleteVar("test");
`);
});
// Combined tests
it('should handle conditional expressions with variable calls', () => {
const code = 'const userStatus = pm.variables.has("userId") ? "logged-in" : "guest";';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const userStatus = bru.hasVar("userId") ? "logged-in" : "guest";');
});
it('should handle all variable methods together', () => {
const code = `
// All variable methods
const hasUserId = pm.variables.has("userId");
const userId = pm.variables.get("userId");
pm.variables.set("requestTime", new Date().toISOString());
console.log(\`Has userId: \${hasUserId}, User ID: \${userId}\`);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const hasUserId = bru.hasVar("userId");');
expect(translatedCode).toContain('const userId = bru.getVar("userId");');
expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
});
it('should handle all collection variable methods together', () => {
const code = `
// All collection variable methods
const hasApiUrl = pm.collectionVariables.has("apiUrl");
const apiUrl = pm.collectionVariables.get("apiUrl");
pm.collectionVariables.set("requestTime", new Date().toISOString());
pm.collectionVariables.unset("tempVar");
console.log(\`Has API URL: \${hasApiUrl}, API URL: \${apiUrl}\`);
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const hasApiUrl = bru.hasVar("apiUrl");');
expect(translatedCode).toContain('const apiUrl = bru.getVar("apiUrl");');
expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
expect(translatedCode).toContain('bru.deleteVar("tempVar");');
});
it('should handle more complex nested expressions with variables', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from '@jest/globals';
import { getMemberExpressionString } from '../../src/utils/jscode-shift-translator';
const j = require('jscodeshift');
describe('getMemberExpressionString', () => {
it('should correctly convert simple member expressions to strings', () => {
// Create a simple member expression: pm.environment.get
const memberExpr = j.memberExpression(
j.memberExpression(
j.identifier('pm'),
j.identifier('environment')
),
j.identifier('get')
);
const result = getMemberExpressionString(memberExpr);
expect(result).toBe('pm.environment.get');
});
it('should handle computed properties with string literals', () => {
// Create a computed member expression: pm["environment"]["get"]
const memberExpr = j.memberExpression(
j.memberExpression(
j.identifier('pm'),
j.literal('environment'),
true // computed
),
j.literal('get'),
true // computed
);
const result = getMemberExpressionString(memberExpr);
expect(result).toBe('pm.environment.get');
});
it('should mark non-string computed properties as [computed]', () => {
// Create a computed member expression with variable: obj[varName]
const memberExpr = j.memberExpression(
j.identifier('obj'),
j.identifier('varName'),
true // computed
);
const result = getMemberExpressionString(memberExpr);
expect(result).toBe('obj.[computed]');
});
it('should handle basic identifiers', () => {
const identifier = j.identifier('pm');
const result = getMemberExpressionString(identifier);
expect(result).toBe('pm');
});
});

View File

@@ -28,6 +28,7 @@
"@aws-sdk/credential-providers": "3.750.0",
"@faker-js/faker": "^9.5.1",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/node-machine-id": "^2.0.0",

View File

@@ -220,7 +220,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
}
// Is this a folder.bru file?
if (path.basename(pathname) === 'folder.bru') {
const file = {
meta: {
@@ -327,16 +326,26 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
}
let name = path.basename(pathname);
let seq = 1;
const folderBruFilePath = path.join(pathname, `folder.bru`);
if (fs.existsSync(folderBruFilePath)) {
let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruData = await collectionBruToJson(folderBruFileContent);
name = folderBruData?.meta?.name || name;
seq = folderBruData?.meta?.seq || seq;
}
const directory = {
meta: {
collectionUid,
pathname,
name
name,
seq,
uid: getRequestUid(pathname)
}
};
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};

View File

@@ -29,9 +29,11 @@ const collectionBruToJson = async (data, parsed = false) => {
// add meta if it exists
// this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang
if (json.meta) {
const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
transformedJson.meta = {
name: json.meta.name
name: json.meta.name,
seq: !isNaN(sequence) ? Number(sequence) : 1
};
}
@@ -61,9 +63,11 @@ const jsonToCollectionBru = async (json, isFolder) => {
// add meta if it exists
// this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang
const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
collectionBruJson.meta = {
name: json.meta.name
name: json.meta.name,
seq: !isNaN(sequence) ? Number(sequence) : 1
};
}

View File

@@ -1,10 +1,13 @@
const _ = require('lodash');
const fs = require('fs');
const fsPromises = require('fs/promises');
const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBru, jsonToBruViaWorker, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
const brunoConverters = require('@usebruno/converters');
const { postmanToBruno } = brunoConverters;
const {
writeFile,
@@ -22,7 +25,10 @@ const {
hasSubDirectories,
getCollectionStats,
sizeInMB,
safeWriteFileSync
safeWriteFileSync,
copyPath,
removePath,
getPaths
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -32,11 +38,10 @@ const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const interpolateVars = require('./network/interpolate-vars');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../utils/collection');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, refreshOauth2Token } = require('../utils/oauth2');
const { getCertsAndProxyConfig } = require('./network');
const { parseBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
@@ -192,12 +197,15 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:save-folder-root', async (event, folder) => {
try {
const { name: folderName, root: folderRoot, pathname: folderPathname } = folder;
const { name: folderName, root: folderRoot = {}, pathname: folderPathname } = folder;
const folderBruFilePath = path.join(folderPathname, 'folder.bru');
folderRoot.meta = {
name: folderName
};
if (!folderRoot.meta) {
folderRoot.meta = {
name: folderName,
seq: 1
};
}
const content = await jsonToCollectionBru(
folderRoot,
@@ -376,14 +384,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else {
folderBruFileJsonContent = {};
folderBruFileJsonContent = {
meta: {
name: newName,
seq: 1
}
};
}
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent);
@@ -425,14 +435,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else {
folderBruFileJsonContent = {};
folderBruFileJsonContent = {
meta: {
name: newName,
seq: 1
}
};
}
folderBruFileJsonContent.meta = {
name: newName,
};
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
await writeFile(folderBruFilePath, folderBruFileContent);
@@ -512,6 +524,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
let data = {
meta: {
name: folderName,
seq: 1
}
};
const content = await jsonToCollectionBru(data, true); // isFolder flag
@@ -598,6 +611,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
item.root.meta.seq = item.seq;
const folderContent = await jsonToCollectionBru(
item.root,
true // isFolder
@@ -731,17 +745,42 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for await (let item of itemsToResequence) {
const bru = fs.readFileSync(item.pathname, 'utf8');
const jsonData = await bruToJsonViaWorker(bru);
if (jsonData.seq !== item.seq) {
jsonData.seq = item.seq;
const content = await jsonToBruViaWorker(jsonData);
await writeFile(item.pathname, content);
for (let item of itemsToResequence) {
if (item?.type === 'folder') {
const folderRootPath = path.join(item.pathname, 'folder.bru');
let folderBruJsonData = {
meta: {
name: path.basename(item?.pathname),
seq: item?.seq || 1
}
};
if (fs.existsSync(folderRootPath)) {
const bru = fs.readFileSync(folderRootPath, 'utf8');
folderBruJsonData = await collectionBruToJson(bru);
if (!folderBruJsonData?.meta) {
folderBruJsonData.meta = {
name: path.basename(item?.pathname),
seq: item?.seq || 1
};
}
if (folderBruJsonData?.meta?.seq === item.seq) {
continue;
}
folderBruJsonData.meta.seq = item.seq;
}
const content = await jsonToCollectionBru(folderBruJsonData);
await writeFile(folderRootPath, content);
} else {
if (fs.existsSync(item.pathname)) {
const itemToSave = transformRequestToSaveToFilesystem(item);
const content = await jsonToBruViaWorker(itemToSave);
await writeFile(item.pathname, content);
}
}
}
return true;
} catch (error) {
console.error('Error in resequence-items:', error);
return Promise.reject(error);
}
});
@@ -760,6 +799,24 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:move-item', async (event, { targetDirname, sourcePathname }) => {
try {
if (fs.existsSync(targetDirname)) {
const sourceDirname = path.dirname(sourcePathname);
const pathnamesBefore = await getPaths(sourcePathname);
const pathnamesAfter = pathnamesBefore?.map(p => p?.replace(sourceDirname, targetDirname));
await copyPath(sourcePathname, targetDirname);
await removePath(sourcePathname);
// move the request uids of the previous file/folders to the new file/folder items
pathnamesAfter?.forEach((_, index) => {
moveRequestUid(pathnamesBefore[index], pathnamesAfter[index]);
});
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => {
try {
const folderName = path.basename(folderPath);
@@ -1096,6 +1153,19 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw error;
}
});
// Implement the Postman to Bruno conversion handler
ipcMain.handle('renderer:convert-postman-to-bruno', async (event, postmanCollection) => {
try {
// Convert Postman collection to Bruno format
const brunoCollection = await postmanToBruno(postmanCollection, { useWorkers: true});
return brunoCollection;
} catch (error) {
console.error('Error converting Postman to Bruno:', error);
return Promise.reject(error);
}
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {

View File

@@ -1255,6 +1255,7 @@ const registerNetworkIpc = (mainWindow) => {
folderUid
});
} catch (error) {
console.log("error", error);
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',

View File

@@ -1,4 +1,4 @@
const { get, each, find, compact, filter } = require('lodash');
const { get, each, find, compact, isString, filter } = require('lodash');
const fs = require('fs');
const { getRequestUid } = require('../cache/requestUids');
const { uuid } = require('./common');
@@ -205,6 +205,14 @@ const findParentItemInCollection = (collection, itemUid) => {
});
};
const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item.uid);
@@ -272,12 +280,73 @@ const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
return '';
}
return str.replaceAll('\t', ' '.repeat(numSpaces));
};
const transformRequestToSaveToFilesystem = (item) => {
const _item = item.draft ? item.draft : item;
const itemToSave = {
uid: _item.uid,
type: _item.type,
name: _item.name,
seq: _item.seq,
request: {
method: _item.request.method,
url: _item.request.url,
params: [],
headers: [],
auth: _item.request.auth,
body: _item.request.body,
script: _item.request.script,
vars: _item.request.vars,
assertions: _item.request.assertions,
tests: _item.request.tests,
docs: _item.request.docs
}
};
each(_item.request.params, (param) => {
itemToSave.request.params.push({
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
type: param.type,
enabled: param.enabled
});
});
each(_item.request.headers, (header) => {
itemToSave.request.headers.push({
uid: header.uid,
name: header.name,
value: header.value,
description: header.description,
enabled: header.enabled
});
});
if (itemToSave.request.body.mode === 'json') {
itemToSave.request.body = {
...itemToSave.request.body,
json: replaceTabsWithSpaces(itemToSave.request.body.json)
};
}
return itemToSave;
}
const sortCollection = (collection) => {
const items = collection.items || [];
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
folderItems = folderItems.sort((a, b) => a.seq - b.seq);
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
collection.items = folderItems.concat(requestItems);
@@ -292,7 +361,7 @@ const sortFolder = (folder = {}) => {
let folderItems = filter(items, (item) => item.type === 'folder');
let requestItems = filter(items, (item) => item.type !== 'folder');
folderItems = folderItems.sort((a, b) => a.name.localeCompare(b.name));
folderItems = folderItems.sort((a, b) => a.seq - b.seq);
requestItems = requestItems.sort((a, b) => a.seq - b.seq);
folder.items = folderItems.concat(requestItems);
@@ -410,11 +479,13 @@ module.exports = {
findItemByPathname,
findItemInCollectionByPathname,
findParentItemInCollection,
findParentItemInCollectionByPathname,
parseBruFileMeta,
hydrateRequestWithUuid,
transformRequestToSaveToFilesystem,
sortCollection,
sortFolder,
getAllRequestsInFolderRecursively,
getEnvVars,
getFormattedCollectionOauth2Credentials,
hydrateRequestWithUuid
getFormattedCollectionOauth2Credentials
};

View File

@@ -164,9 +164,9 @@ const searchForBruFiles = (dir) => {
const sanitizeName = (name) => {
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s]+/, '') // remove leading dots and and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
return name;
};
@@ -175,10 +175,11 @@ const isWindowsOS = () => {
}
const validateName = (name) => {
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // keeping this for informational purpose
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
if (name.length > 255) return false; // max name length
if (reservedDeviceNames.test(name)) return false; // windows reserved names
@@ -282,6 +283,67 @@ function safeWriteFileSync(filePath, data) {
fs.writeFileSync(safePath, data);
}
// Recursively copies a source <file/directory> to a destination <directory>.
const copyPath = async (source, destination) => {
let targetPath = `${destination}/${path.basename(source)}`;
const targetPathExists = await fsPromises.access(targetPath).then(() => true).catch(() => false);
if (targetPathExists) {
throw new Error(`Cannot copy, ${path.basename(source)} already exists in ${path.basename(destination)}`);
}
const copy = async (source, destination) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
await fsPromises.mkdir(destination, { recursive: true });
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const srcPath = path.join(source, entry);
const destPath = path.join(destination, entry);
await copy(srcPath, destPath);
}
} else {
await fsPromises.copyFile(source, destination);
}
}
await copy(source, targetPath);
}
// Recursively removes a source <file/directory>.
const removePath = async (source) => {
const stat = await fsPromises.lstat(source);
if (stat.isDirectory()) {
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const entryPath = path.join(source, entry);
await removePath(entryPath);
}
await fsPromises.rmdir(source);
} else {
await fsPromises.unlink(source);
}
}
// Recursively gets paths.
const getPaths = async (source) => {
let paths = [];
const _getPaths = async (source) => {
const stat = await fsPromises.lstat(source);
paths.push(source);
if (stat.isDirectory()) {
const entries = await fsPromises.readdir(source);
for (const entry of entries) {
const entryPath = path.join(source, entry);
await _getPaths(entryPath);
}
}
}
await _getPaths(source);
return paths;
}
module.exports = {
isValidPathname,
exists,
@@ -308,5 +370,8 @@ module.exports = {
getCollectionStats,
sizeInMB,
safeWriteFile,
safeWriteFileSync
safeWriteFileSync,
copyPath,
removePath,
getPaths
};

View File

@@ -2,9 +2,13 @@ const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath } = r
describe('sanitizeName', () => {
it('should replace invalid characters with hyphens', () => {
const input = '<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F';
const expectedOutput = '----------------------------------------';
expect(sanitizeName(input)).toEqual(expectedOutput);
expect(sanitizeName(
'valid<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F'
)).toEqual('valid----------------------------------------');
expect(sanitizeName(
'<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1Fvalid<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F'
)).toEqual('valid----------------------------------------');
});
it('should not modify valid directory names', () => {

View File

@@ -0,0 +1,116 @@
const path = require('path');
const fs = require('fs/promises');
const os = require('os');
const { copyPath, removePath } = require('../../filesystem');
const { initialCollectionStructure, finalCollectionStructure } = require('../fixtures/filesystem/copypath-removepath');
describe('File System Operations', () => {
let tempDir;
beforeAll(async () => {
// Create a temporary directory for each test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bruno-test-'));
await createFilesAndFolders(tempDir, initialCollectionStructure);
const result = await verifyFilesAndFolders(tempDir, initialCollectionStructure);
expect(result).toBe(true);
});
afterAll(async () => {
// clean up after each test
await fs.rm(tempDir, { recursive: true, force: true });
// confirm the temp directory is deleted
expect(await fs.access(tempDir).then(() => true).catch(() => false)).toBe(false);
});
describe('copyPath and removePath', () => {
it('should move files and folder items multiple times', async () => {
{
const sourcePath = path.join(tempDir, 'folder_1', 'file_2.bru');
const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_2');
const destDir = path.join(tempDir, 'folder_1', 'folder_1_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'file_2_2.bru');
const destDir = path.join(tempDir, 'folder_1');
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
{
const sourcePath = path.join(tempDir, 'folder_1', 'folder_1_1', 'folder_2', 'folder_2_1');
const destDir = path.join(tempDir);
await copyPath(sourcePath, destDir);
await removePath(sourcePath);
}
const result = await verifyFilesAndFolders(tempDir, finalCollectionStructure);
expect(result).toBe(true);
});
it('should throw an error move file/folder if the destination has the same filename', async () => {
{
const sourcePath = path.join(tempDir, 'folder_1', 'file_dup.bru');
const destDir = path.join(tempDir, 'folder_1');
await expect(copyPath(sourcePath, destDir)).rejects.toThrow();
}
});
});
});
// create folders and files recursively based on the defined json structure
const createFilesAndFolders = async (dir, filesAndFolders) => {
for (const item of filesAndFolders) {
const itemPath = path.join(dir, item.name);
if (item.type === 'folder') {
await fs.mkdir(itemPath, { recursive: true });
await createFilesAndFolders(itemPath, item.files);
} else {
await fs.writeFile(itemPath, item.content);
}
}
}
// if a file/folder doesnt exist, return false
// should only contain files and folders that are defined in the json structure
const verifyFilesAndFolders = async (dir, filesAndFolders) => {
const verify = async (dir, filesAndFolders) => {
const files = await fs.readdir(dir);
if (files.length !== filesAndFolders.length) {
return false;
}
for (const file of files) {
const itemPath = path.join(dir, file);
const item = filesAndFolders.find(f => f.name === file);
if (!item) {
return false;
}
if (item.type === 'folder') {
return await verify(itemPath, item.files);
} else {
return await fs.readFile(itemPath, 'utf8').then(content => content === item.content);
}
}
return true;
}
try {
const verified = await verify(dir, filesAndFolders);
return verified;
} catch (error) {
console.error(error);
return false;
}
}

View File

@@ -0,0 +1,155 @@
const initialCollectionStructure = [
{
"name": "folder_1",
"type": "folder",
"files": [
{
"name": "file_1.bru",
"type": "file",
"content": "file_1_content"
},
{
"name": "file_2.bru",
"type": "file",
"content": "file_2_content"
},
{
"name": "folder_1_1",
"type": "folder",
"files": [
{
"name": "file_1_1.bru",
"type": "file",
"content": "file_1_1_content"
},
{
"name": "file_1_2.bru",
"type": "file",
"content": "file_1_2_content"
}
]
},
{
"name": "file_1_3.bru",
"type": "file",
"content": "file_1_3_content"
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
],
},
{
"name": "folder_2",
"type": "folder",
"files": [
{
"name": "file_2_1.bru",
"type": "file",
"content": "file_2_1_content"
},
{
"name": "file_2_2.bru",
"type": "file",
"content": "file_2_2_content"
},
{
"name": "folder_2_1",
"type": "folder",
"files": [
{
"name": "file_2_1_1.bru",
"type": "file",
"content": "file_2_1_1_content"
}
]
}
]
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
];
const finalCollectionStructure = [
{
"name": "folder_1",
"type": "folder",
"files": [
{
"name": "file_1.bru",
"type": "file",
"content": "file_1_content"
},
{
"name": "folder_1_1",
"type": "folder",
"files": [
{
"name": "file_1_1.bru",
"type": "file",
"content": "file_1_1_content"
},
{
"name": "file_1_2.bru",
"type": "file",
"content": "file_1_2_content"
},
{
"name": "file_2.bru",
"type": "file",
"content": "file_2_content"
},
{
"name": "folder_2",
"type": "folder",
"files": [
{
"name": "file_2_1.bru",
"type": "file",
"content": "file_2_1_content"
}
]
}
]
},
{
"name": "file_1_3.bru",
"type": "file",
"content": "file_1_3_content"
},
{
"name": "file_2_2.bru",
"type": "file",
"content": "file_2_2_content"
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
],
},
{
"name": "folder_2_1",
"type": "folder",
"files": [
{
"name": "file_2_1_1.bru",
"type": "file",
"content": "file_2_1_1_content"
}
]
},
{
"name": "file_dup.bru",
"type": "file",
"content": "file_dup_content"
}
];
module.exports = { initialCollectionStructure, finalCollectionStructure };

View File

@@ -35,6 +35,7 @@
"node-vault": "^0.10.2",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"tv4": "^1.3.0",
"uuid": "^9.0.0",
"xml2js": "^0.6.2"
},

Some files were not shown because too many files have changed in this diff Show More