Compare commits

..

1 Commits

Author SHA1 Message Date
Bijin A B
b72fb547a4 fix: update header validation test to use triple-click for selecting all text 2026-02-14 01:30:49 +05:30
172 changed files with 576 additions and 1609 deletions

64
package-lock.json generated
View File

@@ -14555,18 +14555,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -16005,6 +15993,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
@@ -16028,6 +16017,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -18300,6 +18290,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
@@ -21472,6 +21463,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
@@ -22063,6 +22055,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
@@ -26690,50 +26683,6 @@
"node": ">=8"
}
},
"node_modules/shell-env": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.2.tgz",
"integrity": "sha512-8VJLnsyY//uoDJYl7hBcPdX54x0LaKbbfo5htiv8v/jrR4MD7uRUEom6Cb+S54ugMM9GkBbQJSwlLNCI3VXAHQ==",
"license": "MIT",
"dependencies": {
"default-shell": "^2.0.0",
"execa": "^5.1.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shell-env/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/shell-env/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
@@ -26832,6 +26781,7 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/simple-concat": {
@@ -27439,6 +27389,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -35374,7 +35325,6 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"shell-env": "^4.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",

View File

@@ -89,7 +89,7 @@ const EnvironmentVariablesTable = ({
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h + 2);
setTableHeight(h);
}, []);
const prevEnvUidRef = useRef(null);
@@ -495,14 +495,7 @@ const EnvironmentVariablesTable = ({
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>

View File

@@ -28,7 +28,7 @@ const DotEnvTableView = ({
isSaving
}) => {
const handleTotalHeightChanged = useCallback((h) => {
onHeightChange(h + 2);
onHeightChange(h);
}, [onHeightChange]);
// Use refs for stable access to formik values in callbacks

View File

@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 2.2em;
font-size: 1.4em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.7em;
font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h3 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1.45em;
font-size: 1.2em;
}
h4 {
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
h5 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.975em;
font-size: 1em;
}
h6 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.85em;
font-size: 0.9em;
color: var(--color-fg-muted);
}

View File

@@ -1,11 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -17,22 +15,27 @@ const Script = ({ item, collection }) => {
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const activeTab = scriptPaneTab || getDefaultTab();
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevItemUidRef = useRef(item.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different item
useEffect(() => {
if (prevItemUidRef.current !== item.uid) {
prevItemUidRef.current = item.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [item.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
// Small delay to ensure DOM is updated
@@ -73,13 +76,9 @@ const Script = ({ item, collection }) => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
const onScriptTabChange = (tab) => {
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab }));
};
return (
<div className="w-full h-full flex flex-col">
<Tabs value={activeTab} onValueChange={onScriptTabChange}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request

View File

@@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid, formatResponse } from 'utils/common';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { getBodyType } from 'utils/responseBodyProcessor';
@@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
const bodyType = getBodyType(contentType);
const content = formatResponse(response.data, response.dataBuffer, bodyType);
const content = response.data;
const exampleData = {
name: name,

View File

@@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index';
const ResponseDownload = forwardRef(({ item, children }, ref) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer || response.stream?.running;
const isDisabled = !response.dataBuffer ? true : false;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({

View File

@@ -39,7 +39,6 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from '
import { getDefaultRequestPaneTab } from 'utils/collections';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
@@ -562,14 +561,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
const renameKey = isMac ? macRenameKey : winRenameKey;
if (e.key.toLowerCase() === renameKey) {
e.preventDefault();
e.stopPropagation();
setRenameItemModalOpen(true);
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
if (isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
handleCopyItem();

View File

@@ -172,7 +172,7 @@ class SingleLineEditor extends Component {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue && nextValue !== '') {
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();

View File

@@ -99,8 +99,6 @@ const VariablesEditor = ({ collection }) => {
<div className="mt-8 muted text-xs">
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
and <span className="font-medium">setVar()</span>. <br />
You can use the <span className="font-medium">var</span> variable with the
<span className="font-medium">{'{{var}}'}</span> syntax.<br />
</div>
</StyledWrapper>
);

View File

@@ -1,9 +1,8 @@
import React, { useState, useMemo, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
@@ -154,14 +153,6 @@ const CollectionsList = ({ workspace }) => {
setDeleteCollectionModalOpen(true);
};
const handleShowInFolder = (collection) => {
dropdownRefs.current[collection.uid]?.hide();
dispatch(showInFolder(collection.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
return (
<StyledWrapper>
{renameCollectionModalOpen && selectedCollectionUid && (
@@ -210,7 +201,9 @@ const CollectionsList = ({ workspace }) => {
<div className="empty-state">
<IconBox size={32} strokeWidth={1.5} className="empty-icon" />
<h3 className="empty-title">No collections yet</h3>
<p className="empty-description">Create your first collection or open an existing one to get started.</p>
<p className="empty-description">
Create your first collection or open an existing one to get started.
</p>
</div>
) : (
workspaceCollections.map((collection, index) => (
@@ -256,16 +249,6 @@ const CollectionsList = ({ workspace }) => {
<IconShare size={16} strokeWidth={1.5} />
<span>Share</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {
e.stopPropagation();
handleShowInFolder(collection);
}}
>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {

View File

@@ -395,12 +395,11 @@ const GlobalStyle = createGlobalStyle`
font-size: ${(props) => props.theme.font.size.base};
font-family: Inter, sans-serif;
font-weight: 400;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.25rem;
color: ${(props) => props.theme.dropdown.color};
min-height: 1.75rem;
max-width: 17.1875rem;
max-width: 13.1875rem;
}
/* Value Editor (CodeMirror) */

View File

@@ -218,7 +218,7 @@ const SaveRequestsModal = ({ onClose }) => {
</Button>
</div>
<div className="flex gap-2">
<Button color="secondary" variant="ghost" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={closeWithSave}>

View File

@@ -35,7 +35,7 @@ import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
@@ -318,10 +318,6 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeCollectionOauth2CredentialsClearListener = ipcRenderer.on('main:credentials-clear', (val) => {
dispatch(collectionClearOauth2CredentialsByCredentialsId(val));
});
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
dispatch(streamDataReceived(val));
});
@@ -364,7 +360,6 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionOauth2CredentialsClearListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();

View File

@@ -35,8 +35,7 @@ const KeyMapping = {
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' },
renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' }
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }
};
/**

View File

@@ -44,7 +44,7 @@ import {
updateLastAction,
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrlAndCredentialsId,
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent,
updateRunnerConfiguration as _updateRunnerConfiguration,
updateActiveConnections,
@@ -830,7 +830,7 @@ export const renameItem
.invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename, collectionPathname: collection.pathname })
.catch((err) => {
console.error(err);
throw new Error('Duplicate request names are not allowed under the same folder');
throw new Error('Failed to rename the file');
});
};
@@ -2063,14 +2063,7 @@ export const updateVariableInScope = (variableName, newValue, scopeInfo, collect
return reject(new Error('Variable not found'));
}
const updatedVariables = environment.variables.map((v) => {
if (v.uid === variable.uid) {
// Clear ephemeral metadata when user manually edits the value
const { ephemeral, persistedValue, ...rest } = v;
return { ...rest, value: newValue };
}
return v;
});
const updatedVariables = environment.variables.map((v) => (v.uid === variable.uid ? { ...v, value: newValue } : v));
return dispatch(saveEnvironment(updatedVariables, environment.uid, collectionUid))
.then(() => {
@@ -2179,14 +2172,8 @@ export const updateVariableInScope = (variableName, newValue, scopeInfo, collect
return reject(new Error('Variable not found'));
}
const updatedVariables = environment.variables.map((v) => {
if (v.uid === variable.uid) {
// Clear ephemeral metadata when user manually edits the value
const { ephemeral, persistedValue, ...rest } = v;
return { ...rest, value: newValue };
}
return v;
});
const updatedVariables = environment.variables.map((v) =>
v.uid === variable.uid ? { ...v, value: newValue } : v);
return dispatch(saveGlobalEnvironment({ variables: updatedVariables, environmentUid: activeGlobalEnvUid }))
.then(() => {
@@ -2739,43 +2726,11 @@ export const importCollectionFromZip = (zipFilePath, collectionLocation) => asyn
return collectionPath;
};
/**
* Updates Redux collection order and persists it to the active workspace's workspace.yml.
*/
export const moveCollectionAndPersist
= ({ draggedItem, targetItem }) =>
(dispatch, getState) => {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find(
(w) => w.uid === state.workspaces.activeWorkspaceUid
);
if (!activeWorkspace?.pathname || !activeWorkspace.collections?.length) {
return Promise.resolve();
}
const workspacePathSet = new Set(
activeWorkspace.collections.map((wc) => normalizePath(wc.path))
);
const collectionsInWorkspace = state.collections.collections
.filter((c) => workspacePathSet.has(normalizePath(c.pathname)));
if (collectionsInWorkspace.length === 0) {
return Promise.resolve();
}
const reordered = collectionsInWorkspace.filter((i) => i.uid !== draggedItem.uid);
const targetIndex = reordered.findIndex((i) => i.uid === targetItem.uid);
reordered.splice(targetIndex, 0, draggedItem);
const collectionPaths = reordered.map((c) => c.pathname);
return window.ipcRenderer
.invoke('renderer:reorder-workspace-collections', activeWorkspace.pathname, collectionPaths)
.then(() => {
dispatch(moveCollection({ draggedItem, targetItem }));
})
.catch((err) => {
console.error('Failed to reorder workspace collections', err);
return Promise.reject(err);
});
dispatch(moveCollection({ draggedItem, targetItem }));
return Promise.resolve();
};
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
@@ -2883,10 +2838,9 @@ export const clearOauth2Cache = (payload) => async (dispatch, getState) => {
.invoke('clear-oauth2-cache', collectionUid, url, credentialsId)
.then(() => {
dispatch(
collectionClearOauth2CredentialsByUrlAndCredentialsId({
collectionClearOauth2CredentialsByUrl({
url,
collectionUid,
credentialsId
collectionUid
})
);
resolve();

View File

@@ -3217,8 +3217,7 @@ export const collectionsSlice = createSlice({
}
},
// Clears a specific credential matching url + collectionUid + credentialsId (used by UI "Clear OAuth2 Cache")
collectionClearOauth2CredentialsByUrlAndCredentialsId: (state, action) => {
collectionClearOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, url, credentialsId } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
@@ -3228,23 +3227,21 @@ export const collectionsSlice = createSlice({
const filteredOauth2Credentials = filter(
collectionOauth2Credentials,
(creds) =>
!(creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId)
!(creds.url === url && creds.collectionUid === collectionUid)
);
collection.oauth2Credentials = filteredOauth2Credentials;
}
},
// Clears all credentials matching credentialsId regardless of URL (used by script bru.resetOauth2Credential)
collectionClearOauth2CredentialsByCredentialsId: (state, action) => {
const { collectionUid, credentialsId } = action.payload;
collectionGetOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, url, credentialsId } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
if (collection.oauth2Credentials) {
collection.oauth2Credentials = collection.oauth2Credentials.filter(
(creds) => creds.credentialsId !== credentialsId
);
}
const oauth2Credential = find(
collection?.oauth2Credentials || [],
(creds) =>
creds.url === url && creds.collectionUid === collectionUid && creds.credentialsId === credentialsId
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
@@ -3275,9 +3272,10 @@ export const collectionsSlice = createSlice({
timestamp: timestamp || Date.now()
});
}
if (data.dataBuffer) {
if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) {
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
}
item.response.size = data.data?.length + (item.response.size || 0);
}
},
@@ -3679,8 +3677,8 @@ export const {
moveCollection,
streamDataReceived,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrlAndCredentialsId,
collectionClearOauth2CredentialsByCredentialsId,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
addRequestTag,

View File

@@ -63,7 +63,6 @@ export const tabsSlice = createSlice({
responsePaneTab: 'response',
responseFormat: null,
responseViewTab: null,
scriptPaneTab: null,
type: type || 'request',
preview: preview !== undefined
? preview
@@ -86,7 +85,6 @@ export const tabsSlice = createSlice({
responsePaneScrollPosition: null,
responseFormat: null,
responseViewTab: null,
scriptPaneTab: null,
type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
@@ -173,13 +171,6 @@ export const tabsSlice = createSlice({
tab.responseViewTab = action.payload.responseViewTab;
}
},
updateScriptPaneTab: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.scriptPaneTab = action.payload.scriptPaneTab;
}
},
closeTabs: (state, action) => {
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
@@ -273,7 +264,6 @@ export const {
updateResponsePaneScrollPosition,
updateResponseFormat,
updateResponseViewTab,
updateScriptPaneTab,
closeTabs,
closeAllCollectionTabs,
makeTabPermanent,

View File

@@ -24,7 +24,6 @@ const STATIC_API_HINTS = {
'req.setHeader(name, value)',
'req.setHeaders(data)',
'req.deleteHeader(name)',
'req.deleteHeaders(data)',
'req.getBody()',
'req.setBody(data)',
'req.setMaxRedirects(maxRedirects)',
@@ -116,8 +115,7 @@ const STATIC_API_HINTS = {
'bru.cookies.jar().deleteCookie(url, name, callback)',
'bru.utils',
'bru.utils.minifyJson(json)',
'bru.utils.minifyXml(xml)',
'bru.resetOauth2Credential(credentialId)'
'bru.utils.minifyXml(xml)'
]
};

View File

@@ -383,22 +383,6 @@ describe('Bruno Autocomplete', () => {
);
});
it('should provide deleteHeader and deleteHeaders hints for req.delete prefix', () => {
const line = 'req.delete';
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: line.length });
mockedCodemirror.getLine.mockReturnValue(line);
mockedCodemirror.getRange.mockReturnValue(line);
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['req']
});
expect(result).toBeTruthy();
expect(result.list).toEqual(
expect.arrayContaining(['deleteHeader(name)', 'deleteHeaders(data)'])
);
});
it('should handle case-insensitive matching', () => {
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
mockedCodemirror.getLine.mockReturnValue('{{varName}}');

View File

@@ -91,20 +91,9 @@ const getMaskedDisplay = (value) => {
const updateValueDisplay = (valueDisplay, value, isSecret, isMasked, isRevealed) => {
if ((isSecret || isMasked) && !isRevealed) {
valueDisplay.textContent = getMaskedDisplay(value);
return;
} else {
valueDisplay.textContent = value || '';
}
if (typeof value === 'object') {
valueDisplay.textContent = value === null ? 'null' : JSON.stringify(value, null, 2);
return;
}
if (typeof value === 'undefined' || value === undefined) {
valueDisplay.textContent = '';
return;
}
valueDisplay.textContent = value;
};
// Check if the raw value contains references to secret variables
@@ -233,7 +222,7 @@ export const renderVarInfo = (token, options) => {
// If variable doesn't exist in any scope, determine scope based on context
if (!scopeInfo) {
if (item && item.uid) {
if (item) {
// Determine if item is a folder or request
const isFolder = item.type === 'folder';

View File

@@ -173,8 +173,8 @@ const curlToJson = (curlCommand) => {
requestJson.headers = {};
}
requestJson.headers['Content-Type'] = 'multipart/form-data';
} else if (request.isDataBinary && (typeof request.data === 'string' && request.data.startsWith('@'))) {
Object.assign(requestJson, getFilesString(request)); // file case
} else if (request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));
}

View File

@@ -522,8 +522,8 @@ const cleanRequest = (request) => {
* Handles escape sequences, line continuations, and method concatenation
*/
const cleanCurlCommand = (curlCommand) => {
// Handle bash ANSI $'..' escapes by decoding common sequences
curlCommand = curlCommand.replace(/\$'((?:\\.|[^'])*)'/g, (match, group) => quoteForShell(decodeAnsiEscapes(group)));
// Handle escape sequences
curlCommand = curlCommand.replace(/\$('.*')/g, (match, group) => group);
// Convert escaped single quotes to shell quote pattern
curlCommand = curlCommand.replace(/\\'(?!')/g, '\'\\\'\'');
// Fix concatenated HTTP methods
@@ -555,33 +555,4 @@ const fixConcatenatedMethods = (command) => {
return command;
};
/**
* Decode bash ANSI $'..' escape sequences
*/
const decodeAnsiEscapes = (value) => {
return value.replace(/\\(\\|'|n|r|t|v|f|a|b|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4})/g, (match, seq) => {
switch (seq[0]) {
case '\\': return '\\';
case '\'': return '\'';
case 'n': return '\n';
case 'r': return '\r';
case 't': return '\t';
case 'v': return '\v';
case 'f': return '\f';
case 'a': return '\x07';
case 'b': return '\b';
case 'x': return String.fromCharCode(parseInt(seq.slice(1), 16));
case 'u': return String.fromCharCode(parseInt(seq.slice(1), 16));
default: return match;
}
});
};
/**
* Wrap value in single quotes while preserving embedded single quotes
*/
const quoteForShell = (value) => {
return `'${value.replace(/'/g, '\'\\\'\'')}'`;
};
export default parseCurlCommand;

View File

@@ -1,6 +1,5 @@
const yargs = require('yargs');
const chalk = require('chalk');
const { initializeShellEnv } = require('@usebruno/requests');
const { CLI_EPILOGUE, CLI_VERSION } = require('./constants');
@@ -9,9 +8,6 @@ const printBanner = () => {
};
const run = async () => {
// Fetch shell environment (useful when CLI is run as subprocess from GUI app or cron)
await initializeShellEnv();
const argLength = process.argv.length;
const commandsToPrintBanner = ['--help', '-h'];

View File

@@ -23,8 +23,7 @@ const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { getOAuth2Token } = require('../utils/oauth2');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
@@ -226,12 +225,6 @@ const runSingleRequest = async function (
shouldStopRunnerExecution = true;
}
if (result?.oauth2CredentialsToReset?.length) {
for (const credentialId of result.oauth2CredentialsToReset) {
tokenStore.deleteCredentialById(credentialId);
}
}
if (result?.skipRequest) {
return {
test: {
@@ -640,8 +633,6 @@ const runSingleRequest = async function (
console.error('OAuth2 token fetch error:', error.message);
}
request.oauth2CredentialVariables = getFormattedOauth2Credentials();
// Remove oauth2 config from request to prevent it from being sent
delete request.oauth2;
}
@@ -656,8 +647,7 @@ const runSingleRequest = async function (
let axiosInstance = makeAxiosInstance({
requestMaxRedirects: requestMaxRedirects,
disableCookies: options.disableCookies,
followRedirects: followRedirects
disableCookies: options.disableCookies
});
if (request.ntlmConfig) {
@@ -796,12 +786,6 @@ const runSingleRequest = async function (
shouldStopRunnerExecution = true;
}
if (result?.oauth2CredentialsToReset?.length) {
for (const credentialId of result.oauth2CredentialsToReset) {
tokenStore.deleteCredentialById(credentialId);
}
}
postResponseTestResults = result?.results || [];
logResults(postResponseTestResults, 'Post-Response Tests');
} catch (error) {
@@ -873,12 +857,6 @@ const runSingleRequest = async function (
shouldStopRunnerExecution = true;
}
if (result?.oauth2CredentialsToReset?.length) {
for (const credentialId of result.oauth2CredentialsToReset) {
tokenStore.deleteCredentialById(credentialId);
}
}
logResults(testResults, 'Tests');
} catch (error) {
console.error('Test script execution error:', error);

View File

@@ -29,15 +29,6 @@ const tokenStore = {
return false;
},
// Delete all credentials for a given credentialsId (all URLs)
deleteCredentialById(credentialsId) {
if (this.credentials[credentialsId]) {
delete this.credentials[credentialsId];
return true;
}
return false;
},
// Get all stored OAuth2 credentials
getAllCredentials() {
const result = [];

View File

@@ -71,7 +71,7 @@ const createRedirectConfig = (error, redirectUrl) => {
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedirects = true } = {}) {
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
@@ -113,14 +113,6 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi
error.response.headers['request-duration'] = end - start;
if (redirectResponseCodes.includes(error.response.status)) {
if (!followRedirects) {
if (!disableCookies) {
saveCookies(error.config.url, error.response.headers);
}
return Promise.reject(error);
}
if (redirectCount >= requestMaxRedirects) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);

View File

@@ -11,7 +11,6 @@ const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
const REQUEST_ITEM_TYPES = ['http-request', 'graphql-request'];
const getCollectionFormat = (collectionPath) => {
if (fs.existsSync(path.join(collectionPath, 'opencollection.yml'))) return 'yml';
@@ -500,7 +499,7 @@ const processCollectionItems = async (items = [], currentPath) => {
if (item.seq) {
item.root.meta.seq = item.seq;
}
const folderContent = stringifyFolder(item.root, { format: 'bru' });
const folderContent = await stringifyFolder(item.root);
safeWriteFileSync(folderBruFilePath, folderContent);
}
@@ -508,16 +507,17 @@ const processCollectionItems = async (items = [], currentPath) => {
if (item.items && item.items.length) {
await processCollectionItems(item.items, folderPath);
}
} else if (REQUEST_ITEM_TYPES.includes(item.type)) {
} 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 = {
// Keep schema item type so filestore can stringify request correctly
type: item.type,
type: type,
name: item.name,
seq: typeof item.seq === 'number' ? item.seq : 1,
tags: item.tags || [],
@@ -538,10 +538,8 @@ const processCollectionItems = async (items = [], currentPath) => {
};
// Convert to BRU format and write to file
const content = stringifyRequest(bruJson, { format: 'bru' });
const content = await stringifyRequest(bruJson);
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
} else {
throw new Error(`Unsupported item type: ${item.type}`);
}
}
};

View File

@@ -1,143 +0,0 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { describe, it, expect, afterEach } = require('@jest/globals');
const { parseRequest, parseFolder } = require('@usebruno/filestore');
const { createCollectionFromBrunoObject } = require('../../../src/utils/collection');
describe('createCollectionFromBrunoObject', () => {
let outputDir;
const createOutputDir = () => {
outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-cli-import-'));
return outputDir;
};
const parseBruRequestFromPath = (filePath) => parseRequest(fs.readFileSync(filePath, 'utf8'), { format: 'bru' });
const parseBruFolderFromPath = (filePath) => parseFolder(fs.readFileSync(filePath, 'utf8'), { format: 'bru' });
afterEach(() => {
if (outputDir && fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true, force: true });
}
});
it('writes http and graphql requests from imported collection items', async () => {
createOutputDir();
await createCollectionFromBrunoObject(
{
name: 'imported-collection',
items: [
{
type: 'http-request',
name: 'Get Users',
filename: 'get-users.bru',
seq: 1,
request: {
method: 'GET',
url: 'https://api.example.com/users'
}
},
{
type: 'graphql-request',
name: 'Get Viewer',
filename: 'get-viewer.bru',
seq: 2,
request: {
method: 'POST',
url: 'https://api.example.com/graphql',
body: {
mode: 'graphql',
graphql: {
query: 'query { viewer { id } }',
variables: '{}'
}
}
}
}
]
},
outputDir
);
const httpPath = path.join(outputDir, 'get-users.bru');
const graphqlPath = path.join(outputDir, 'get-viewer.bru');
expect(fs.existsSync(httpPath)).toBe(true);
expect(fs.existsSync(graphqlPath)).toBe(true);
const httpRequest = parseBruRequestFromPath(httpPath);
const graphqlRequest = parseBruRequestFromPath(graphqlPath);
expect(httpRequest).toHaveProperty('type', 'http-request');
expect(httpRequest).toHaveProperty('request.method', 'GET');
expect(graphqlRequest).toHaveProperty('type', 'graphql-request');
expect(graphqlRequest).toHaveProperty('request.method', 'POST');
});
it('writes folder.bru in bru format', async () => {
createOutputDir();
await createCollectionFromBrunoObject(
{
name: 'folder-collection',
items: [
{
type: 'folder',
name: 'Users',
seq: 3,
root: {
meta: { name: 'Users' }
},
items: [
{
type: 'http-request',
name: 'List Users',
filename: 'list-users.bru',
seq: 1,
request: {
method: 'GET',
url: 'https://api.example.com/users'
}
}
]
}
]
},
outputDir
);
const folderPath = path.join(outputDir, 'Users');
const folderBruPath = path.join(folderPath, 'folder.bru');
const nestedRequestPath = path.join(folderPath, 'list-users.bru');
expect(fs.existsSync(folderBruPath)).toBe(true);
expect(fs.existsSync(nestedRequestPath)).toBe(true);
const folder = parseBruFolderFromPath(folderBruPath);
const nestedRequest = parseBruRequestFromPath(nestedRequestPath);
expect(folder).toHaveProperty('meta.name', 'Users');
expect(folder).toHaveProperty('meta.seq', 3);
expect(nestedRequest).toHaveProperty('type', 'http-request');
expect(nestedRequest).toHaveProperty('request.method', 'GET');
});
it('throws for unsupported item types', async () => {
createOutputDir();
await expect(
createCollectionFromBrunoObject(
{
name: 'invalid-item-type-collection',
items: [
{
type: 'unsupported-type',
name: 'Unsupported'
}
]
},
outputDir
)
).rejects.toThrow('Unsupported item type: unsupported-type');
});
});

View File

@@ -301,7 +301,7 @@ const parseInsomniaCollection = (data) => {
export const insomniaToBruno = (insomniaCollection) => {
try {
if (typeof insomniaCollection !== 'object') {
insomniaCollection = jsyaml.load(insomniaCollection, { schema: jsyaml.JSON_SCHEMA });
insomniaCollection = jsyaml.load(insomniaCollection);
}
let collection;
if (isInsomniaV5Export(insomniaCollection)) {

View File

@@ -540,7 +540,6 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
}).filter((tag) => tag.trim())
)],
request: {
docs: _operationObject.description,
url: ensureUrl(request.global.server + path),
method: request.method.toUpperCase(),
auth: {

View File

@@ -24,7 +24,7 @@ const toOpenCollectionConfig = (brunoConfig: BrunoConfig | undefined): Collectio
if (brunoConfig.protobuf.importPaths?.length) {
config.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((p) => {
const importPath: { path: string; disabled?: boolean } = { path: p.path };
if (p.enabled === false) {
if (p.disabled) {
importPath.disabled = true;
}
return importPath;

View File

@@ -48,7 +48,7 @@ const fromOpenCollectionConfig = (oc: OpenCollection): BrunoConfig => {
})),
importPaths: config.protobuf.importPaths?.map((p) => ({
path: p.path,
enabled: p.disabled !== true
disabled: p.disabled || false
}))
};
}

View File

@@ -177,7 +177,7 @@ export interface BrunoConfig {
};
protobuf?: {
protoFiles?: { path: string }[];
importPaths?: { path: string; enabled?: boolean }[];
importPaths?: { path: string; disabled?: boolean }[];
};
proxy?: {
disabled?: boolean;

View File

@@ -70,7 +70,7 @@ const simpleTranslations = {
'req.headers': 'pm.request.headers',
'req.body': 'pm.request.body',
'req.getHeader': 'pm.request.headers.get',
// Note: req.setHeader is handled in complexTransformations because it needs arg restructuring (two args -> object)
'req.setHeader': 'pm.request.headers.set',
'req.deleteHeader': 'pm.request.headers.remove',
// URL helper methods
@@ -318,28 +318,6 @@ const complexTransformations = [
return updateCall;
}
},
// req.setHeader(key, value) -> pm.request.headers.upsert({key: key, value: value})
{
pattern: 'req.setHeader',
transform: (path) => {
const args = path.value.arguments;
if (!args || args.length < 2) {
return j.callExpression(
buildMemberExpressionFromString('pm.request.headers.upsert'),
args || []
);
}
return j.callExpression(
buildMemberExpressionFromString('pm.request.headers.upsert'),
[
j.objectExpression([
j.property('init', j.identifier('key'), args[0]),
j.property('init', j.identifier('value'), args[1])
])
]
);
}
},
// req.setHeaders(headers) -> loop calling pm.request.headers.upsert() for each header
{
pattern: 'req.setHeaders',

View File

@@ -73,22 +73,10 @@ describe('Bruno to Postman Request Translation', () => {
expect(translatedCode).toBe('const contentType = pm.request.headers.get("Content-Type");');
});
it('should translate req.setHeader() to pm.request.headers.upsert() with object arg', () => {
it('should translate req.setHeader() to pm.request.headers.set()', () => {
const code = 'req.setHeader("Authorization", "Bearer token123");';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toContain('pm.request.headers.upsert({\n key: "Authorization",\n value: "Bearer token123"\n})');
});
it('should translate req.deleteHeader() to pm.request.headers.remove()', () => {
const code = 'req.deleteHeader("Authorization");';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('pm.request.headers.remove("Authorization");');
});
it('should handle req.deleteHeader() with a variable argument', () => {
const code = 'const headerName = "X-Custom"; req.deleteHeader(headerName);';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('const headerName = "X-Custom"; pm.request.headers.remove(headerName);');
expect(translatedCode).toBe('pm.request.headers.set("Authorization", "Bearer token123");');
});
it('should handle all request properties together', () => {

View File

@@ -33,9 +33,6 @@ collection:
isPrivate: false
sortKey: -1744194421965
method: GET
parameters:
- name: date
value: 2022-10-28
settings:
renderRequestBody: true
encodeUrl: true
@@ -130,14 +127,7 @@ const expectedOutput = {
},
headers: [],
method: 'GET',
params: [
{
enabled: true,
name: 'date',
type: 'query',
value: '2022-10-28'
}
],
params: [],
url: 'https://testbench-sanity.usebruno.com/ping'
},
seq: 1,

View File

@@ -36,14 +36,14 @@ const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'bruno.json';
return dirname === collectionPath && basename === 'bruno.json';
};
const isEnvironmentsFolder = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const envDirectory = path.join(collectionPath, 'environments');
return path.normalize(dirname) === path.normalize(envDirectory);
return dirname === envDirectory;
};
const isFolderRootFile = (pathname, collectionPath) => {
@@ -64,7 +64,7 @@ const isCollectionRootFile = (pathname, collectionPath) => {
const basename = path.basename(pathname);
// return if we are not at the root of the collection
if (path.normalize(dirname) !== path.normalize(collectionPath)) {
if (dirname !== collectionPath) {
return false;
}
@@ -385,7 +385,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (path.normalize(pathname) === path.normalize(envDirectory)) {
if (pathname === envDirectory) {
return;
}
@@ -563,7 +563,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
if (basename === 'opencollection.yml' && dirname === collectionPath) {
return;
}
@@ -581,7 +581,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (path.normalize(pathname) === path.normalize(envDirectory)) {
if (pathname === envDirectory) {
return;
}

View File

@@ -3,7 +3,6 @@ const path = require('path');
const { execSync } = require('node:child_process');
const isDev = require('electron-is-dev');
const os = require('os');
const { initializeShellEnv } = require('@usebruno/requests');
if (isDev) {
if (!fs.existsSync(path.join(__dirname, '../../bruno-js/src/sandbox/bundle-browser-rollup.js'))) {
@@ -156,9 +155,6 @@ if (useSingleInstance && !gotTheLock) {
// Prepare the renderer once the app is ready
app.on('ready', async () => {
// Ensure shell environment is loaded before any operations that need it
await initializeShellEnv();
if (isDev) {
const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
try {

View File

@@ -84,27 +84,6 @@ const MAX_COLLECTION_SIZE_IN_MB = 20;
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 5;
const MAX_COLLECTION_FILES_COUNT = 2000;
// Get the base directory for transient request files (stored in app data directory)
const getTransientDirectoryBase = () => {
return path.join(app.getPath('userData'), 'tmp', 'transient');
};
// Get the prefix used for transient collection directories
const getTransientCollectionPrefix = () => {
return path.join(getTransientDirectoryBase(), 'bruno-');
};
// Get the prefix used for scratch collection directories
const getTransientScratchPrefix = () => {
return path.join(getTransientDirectoryBase(), 'bruno-scratch-');
};
// Check if a path is within the transient directory
const isTransientPath = (filePath) => {
const transientBase = getTransientDirectoryBase();
return filePath.startsWith(transientBase + path.sep) || filePath.startsWith(transientBase);
};
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -112,10 +91,11 @@ const envHasSecrets = (environment = {}) => {
};
const findCollectionPathByItemPath = (filePath) => {
const tmpDir = os.tmpdir();
const parts = filePath.split(path.sep);
const index = parts.findIndex((part) => part.startsWith('bruno-'));
if (isTransientPath(filePath) && index !== -1) {
if (filePath.startsWith(tmpDir) && index !== -1) {
const transientDirPath = parts.slice(0, index + 1).join(path.sep);
const metadataPath = path.join(transientDirPath, 'metadata.json');
try {
@@ -141,12 +121,8 @@ const findCollectionPathByItemPath = (filePath) => {
// Sort by length descending to find the most specific (deepest) match first
const sortedPaths = allCollectionPaths.sort((a, b) => b.length - a.length);
// Normalize the file path for comparison
const normalizedFilePath = path.normalize(filePath);
for (const collectionPath of sortedPaths) {
const normalizedCollectionPath = path.normalize(collectionPath);
if (normalizedFilePath.startsWith(normalizedCollectionPath + path.sep) || normalizedFilePath === normalizedCollectionPath) {
if (filePath.startsWith(collectionPath + path.sep) || filePath === collectionPath) {
return collectionPath;
}
}
@@ -1045,10 +1021,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
// This is a simpler handler specifically for cleaning up transient requests
// tempDirectory: the collection's temp directory path to validate files belong to this collection
ipcMain.handle('renderer:delete-transient-requests', async (event, filePaths, tempDirectory) => {
const brunoTempPrefix = getTransientCollectionPrefix();
const brunoTempPrefix = path.join(os.tmpdir(), 'bruno-');
const results = { deleted: [], skipped: [], errors: [] };
// Validate tempDirectory is within Bruno transient directory
// Validate tempDirectory is within Bruno temp prefix
const normalizedTempDir = tempDirectory ? path.normalize(tempDirectory) : null;
if (!normalizedTempDir || !normalizedTempDir.startsWith(brunoTempPrefix)) {
return { deleted: [], skipped: filePaths.map((p) => ({ path: p, reason: 'Invalid temp directory' })), errors: [] };
@@ -1921,12 +1897,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => {
let tempDirectoryPath = null;
try {
// Ensure the transient base directory exists
const transientBase = getTransientDirectoryBase();
if (!fs.existsSync(transientBase)) {
fs.mkdirSync(transientBase, { recursive: true });
}
tempDirectoryPath = fs.mkdtempSync(getTransientCollectionPrefix());
tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-'));
const metadata = {
collectionPath: collectionPathname
};
@@ -1955,12 +1926,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
ipcMain.handle('renderer:mount-workspace-scratch', async (event, { workspaceUid, workspacePath }) => {
try {
// Ensure the transient base directory exists
const transientBase = getTransientDirectoryBase();
if (!fs.existsSync(transientBase)) {
fs.mkdirSync(transientBase, { recursive: true });
}
const tempDirectoryPath = fs.mkdtempSync(getTransientScratchPrefix());
const tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-scratch-'));
registerScratchCollectionPath(tempDirectoryPath);
const collectionRoot = {

View File

@@ -128,31 +128,15 @@ const authorizeUserInWindow = ({ authorizeUrl, callbackUrl, session, additionalH
function onWindowRedirect(url) {
// Handle redirects as needed
let urlObj;
let callbackUrlObj;
try {
urlObj = new URL(url);
} catch (e) {
// Invalid redirect URL, skip processing
return;
}
try {
callbackUrlObj = new URL(callbackUrl);
} catch (e) {
// Invalid callback URL, skip matching but still check for errors below
callbackUrlObj = null;
}
// Check if redirect is to the callback URL and contains an authorization code
if (callbackUrlObj && matchesCallbackUrl(urlObj, callbackUrlObj)) {
if (matchesCallbackUrl(new URL(url), new URL(callbackUrl))) {
finalUrl = url;
window.close();
return;
}
// Handle OAuth error responses
const urlObj = new URL(url);
if (urlObj.searchParams.has('error')) {
const error = urlObj.searchParams.get('error');
const errorDescription = urlObj.searchParams.get('error_description');

View File

@@ -76,8 +76,7 @@ function makeAxiosInstance({
proxyConfig = {},
requestMaxRedirects = 5,
httpsAgentRequestFields = {},
interpolationOptions = {},
followRedirects = true
interpolationOptions = {}
} = {}) {
/** @type {axios.AxiosInstance} */
const instance = axios.create({
@@ -278,14 +277,6 @@ function makeAxiosInstance({
// Attach the timeline to the response
error.response.timeline = timeline;
if (!followRedirects) {
if (preferencesUtil.shouldStoreCookies()) {
saveCookies(error.config.url, error.response.headers);
}
return Promise.reject(error);
}
if (redirectCount >= requestMaxRedirects) {
const errorResponseData = error.response.data;
timeline?.push({

View File

@@ -25,7 +25,7 @@ const { chooseFileToSave, writeFile, getCollectionFormat, hasRequestExtension }
const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials, clearOauth2CredentialsByCredentialsId } = require('../../utils/oauth2');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
@@ -149,8 +149,7 @@ const configureRequest = async (
proxyConfig,
requestMaxRedirects,
httpsAgentRequestFields,
interpolationOptions,
followRedirects
interpolationOptions
});
if (request.ntlmConfig) {
@@ -477,25 +476,6 @@ const registerNetworkIpc = (mainWindow) => {
};
};
const resetOauth2Credentials = ({ oauth2CredentialsToReset, request, collectionUid }) => {
if (!oauth2CredentialsToReset?.length) return;
for (const credentialId of oauth2CredentialsToReset) {
clearOauth2CredentialsByCredentialsId({ collectionUid, credentialsId: credentialId });
if (request?.oauth2Credentials?.credentialsId === credentialId) {
request.oauth2Credentials = null;
}
const prefix = `$oauth2.${credentialId}.`;
if (request.oauth2CredentialVariables) {
for (const key of Object.keys(request.oauth2CredentialVariables)) {
if (key.startsWith(prefix)) {
delete request.oauth2CredentialVariables[key];
}
}
}
mainWindow.webContents.send('main:credentials-clear', { collectionUid, credentialsId: credentialId });
}
};
const runPreRequest = async (
request,
requestUid,
@@ -547,8 +527,6 @@ const registerNetworkIpc = (mainWindow) => {
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
resetOauth2Credentials({ oauth2CredentialsToReset: scriptResult.oauth2CredentialsToReset, request, collectionUid });
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
}
@@ -687,8 +665,6 @@ const registerNetworkIpc = (mainWindow) => {
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
resetOauth2Credentials({ oauth2CredentialsToReset: scriptResult.oauth2CredentialsToReset, request, collectionUid });
const domainsWithCookiesPost = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPost)));
}
@@ -839,7 +815,7 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
if (request.oauth2Credentials?.credentials && request.oauth2Credentials?.credentialsId) {
if (request?.oauth2Credentials) {
mainWindow.webContents.send('main:credentials-update', {
credentials: request?.oauth2Credentials?.credentials,
url: request?.oauth2Credentials?.url,
@@ -848,12 +824,6 @@ const registerNetworkIpc = (mainWindow) => {
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
debugInfo: request?.oauth2Credentials?.debugInfo
});
const { credentialsId, credentials } = request.oauth2Credentials;
request.oauth2CredentialVariables = request.oauth2CredentialVariables || {};
Object.entries(credentials).forEach(([key, value]) => {
request.oauth2CredentialVariables[`$oauth2.${credentialsId}.${key}`] = value;
});
}
let response, responseTime, axiosDataStream;
@@ -1061,8 +1031,6 @@ const registerNetworkIpc = (mainWindow) => {
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
resetOauth2Credentials({ oauth2CredentialsToReset: testResults.oauth2CredentialsToReset, request, collectionUid });
!runInBackground && notifyScriptExecution({
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
@@ -1518,7 +1486,7 @@ const registerNetworkIpc = (mainWindow) => {
collection.globalEnvironmentVariables
);
if (request.oauth2Credentials?.credentials && request.oauth2Credentials?.credentialsId) {
if (request?.oauth2Credentials) {
mainWindow.webContents.send('main:credentials-update', {
credentials: request?.oauth2Credentials?.credentials,
url: request?.oauth2Credentials?.url,
@@ -1528,12 +1496,6 @@ const registerNetworkIpc = (mainWindow) => {
debugInfo: request?.oauth2Credentials?.debugInfo
});
const { credentialsId, credentials } = request.oauth2Credentials;
request.oauth2CredentialVariables = request.oauth2CredentialVariables || {};
Object.entries(credentials).forEach(([key, value]) => {
request.oauth2CredentialVariables[`$oauth2.${credentialsId}.${key}`] = value;
});
collection.oauth2Credentials = updateCollectionOauth2Credentials({
itemUid: item.uid,
collectionUid,
@@ -1775,8 +1737,6 @@ const registerNetworkIpc = (mainWindow) => {
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
resetOauth2Credentials({ oauth2CredentialsToReset: testResults.oauth2CredentialsToReset, request, collectionUid });
notifyScriptExecution({
channel: 'main:run-folder-event',
basePayload: eventData,

View File

@@ -20,7 +20,6 @@ const {
updateWorkspaceDocs,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
reorderWorkspaceCollections,
getWorkspaceCollections,
normalizeCollectionEntry,
validateWorkspacePath,
@@ -191,18 +190,6 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => {
}
});
ipcMain.handle('renderer:reorder-workspace-collections', async (event, workspacePath, collectionPaths) => {
try {
if (!workspacePath) {
throw new Error('Workspace path is undefined');
}
validateWorkspacePath(workspacePath);
await reorderWorkspaceCollections(workspacePath, collectionPaths);
} catch (error) {
throw error;
}
});
ipcMain.handle('renderer:load-workspace-apispecs', async (event, workspacePath) => {
try {
if (!workspacePath) {

View File

@@ -150,23 +150,6 @@ class Oauth2Store {
}
}
clearCredentialsByCredentialsId({ collectionUid, credentialsId }) {
try {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid });
let filteredCredentials = oauth2DataForCollection?.credentials?.filter(
(c) => c?.credentialsId !== credentialsId
);
let newOauth2DataForCollection = {
...oauth2DataForCollection,
credentials: filteredCredentials
};
this.updateOauth2DataOfCollection({ collectionUid, data: newOauth2DataForCollection });
return newOauth2DataForCollection;
} catch (err) {
console.log('error clearing oauth2 credentials by credentialsId from cache', err);
}
}
clearCredentialsForCollection({ collectionUid, url, credentialsId }) {
try {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });

View File

@@ -442,7 +442,7 @@ const isDotEnvFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === '.env';
return dirname === collectionPath && basename === '.env';
};
const isValidDotEnvFilename = (filename) => {
@@ -456,7 +456,7 @@ const isBrunoConfigFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'bruno.json';
return dirname === collectionPath && basename === 'bruno.json';
};
const isBruEnvironmentConfig = (pathname, collectionPath) => {
@@ -464,14 +464,14 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(envDirectory) && hasBruExtension(basename);
return dirname === envDirectory && hasBruExtension(basename);
};
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
return path.normalize(dirname) === path.normalize(collectionPath) && basename === 'collection.bru';
return dirname === collectionPath && basename === 'collection.bru';
};
const scanForBrunoFiles = async (dir) => {

View File

@@ -25,10 +25,6 @@ const clearOauth2Credentials = ({ collectionUid, url, credentialsId }) => {
oauth2Store.clearCredentialsForCollection({ collectionUid, url, credentialsId });
};
const clearOauth2CredentialsByCredentialsId = ({ collectionUid, credentialsId }) => {
oauth2Store.clearCredentialsByCredentialsId({ collectionUid, credentialsId });
};
const getStoredOauth2Credentials = ({ collectionUid, url, credentialsId }) => {
try {
const credentials = oauth2Store.getCredentialsForCollection({ collectionUid, url, credentialsId });
@@ -945,7 +941,6 @@ const updateCollectionOauth2Credentials = ({ collectionUid, itemUid, collectionO
module.exports = {
persistOauth2Credentials,
clearOauth2Credentials,
clearOauth2CredentialsByCredentialsId,
getStoredOauth2Credentials,
getOAuth2TokenUsingAuthorizationCode,
getOAuth2TokenUsingClientCredentials,

View File

@@ -5,9 +5,6 @@ const { writeFile, validateName, isValidCollectionDirectory } = require('./files
const { generateUidBasedOnHash } = require('./common');
const { withLock, getWorkspaceLockKey } = require('./workspace-lock');
// Normalize Windows backslash paths to forward slashes for cross-platform compatibility.
const posixifyPath = (p) => p.replace(/\\/g, '/');
const WORKSPACE_TYPE = 'workspace';
const OPENCOLLECTION_VERSION = '1.0.0';
@@ -97,7 +94,7 @@ const sanitizeCollections = (collections) => {
}).map((collection) => {
const sanitized = {
name: collection.name.trim(),
path: posixifyPath(collection.path.trim())
path: collection.path.trim()
};
if (collection.remote && typeof collection.remote === 'string') {
@@ -121,32 +118,26 @@ const sanitizeSpecs = (specs) => {
return true;
}).map((spec) => ({
name: spec.name.trim(),
path: posixifyPath(spec.path.trim())
path: spec.path.trim()
}));
};
const makeRelativePath = (workspacePath, absolutePath) => {
if (!path.isAbsolute(absolutePath)) {
return posixifyPath(absolutePath);
return absolutePath;
}
try {
const relativePath = path.relative(workspacePath, absolutePath);
if (relativePath.startsWith('..') && relativePath.split(path.sep).filter((s) => s === '..').length > 2) {
return posixifyPath(absolutePath);
return absolutePath;
}
return posixifyPath(relativePath);
return relativePath;
} catch (error) {
return posixifyPath(absolutePath);
return absolutePath;
}
};
const getNormalizedAbsoluteCollectionPath = (workspacePath, collection) => {
if (!collection?.path) return null;
const resolved = path.isAbsolute(collection.path) ? collection.path : path.resolve(workspacePath, collection.path);
return path.normalize(resolved);
};
const normalizeCollectionEntry = (workspacePath, collection) => {
const relativePath = makeRelativePath(workspacePath, collection.path);
@@ -344,14 +335,14 @@ const addCollectionToWorkspace = async (workspacePath, collection) => {
const normalizedCollection = {
name: collection.name.trim(),
path: posixifyPath(collection.path.trim())
path: collection.path.trim()
};
if (collection.remote && typeof collection.remote === 'string') {
normalizedCollection.remote = collection.remote.trim();
}
const existingIndex = config.collections.findIndex((c) => c.path && posixifyPath(c.path) === normalizedCollection.path);
const existingIndex = config.collections.findIndex((c) => c.path === normalizedCollection.path);
if (existingIndex >= 0) {
config.collections[existingIndex] = normalizedCollection;
@@ -372,7 +363,7 @@ const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => {
let removedCollection = null;
config.collections = (config.collections || []).filter((c) => {
const collectionPathFromYml = c.path ? posixifyPath(c.path) : c.path;
const collectionPathFromYml = c.path;
if (!collectionPathFromYml) {
return true;
@@ -400,43 +391,6 @@ const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => {
});
};
/**
* Reorders the collections array in the workspace's workspace.yml to match the given path list.
* Entries not in the list are appended at the end.
* @param {string} workspacePath - Absolute path to the workspace directory
* @param {string[]} collectionPaths - Absolute collection pathnames in the desired order
*/
const reorderWorkspaceCollections = async (workspacePath, collectionPaths) => {
if (!Array.isArray(collectionPaths)) {
throw new Error('collectionPaths must be an array');
}
return withLock(getWorkspaceLockKey(workspacePath), async () => {
const config = readWorkspaceConfig(workspacePath);
const existing = config.collections || [];
const inNewOrder = [];
const matched = new Set();
for (const absolutePath of collectionPaths) {
const targetPath = posixifyPath(path.normalize(absolutePath));
const entry = existing.find(
(c) => posixifyPath(getNormalizedAbsoluteCollectionPath(workspacePath, c)) === targetPath
);
if (entry && !matched.has(entry)) {
inNewOrder.push(entry);
matched.add(entry);
}
}
const notInList = existing.filter((c) => !matched.has(c));
config.collections = [...inNewOrder, ...notInList];
const yamlContent = generateYamlContent(config);
await writeWorkspaceFileAtomic(workspacePath, yamlContent);
});
};
const getWorkspaceCollections = (workspacePath) => {
const config = readWorkspaceConfig(workspacePath);
const collections = config.collections || [];
@@ -444,14 +398,13 @@ const getWorkspaceCollections = (workspacePath) => {
const seenPaths = new Set();
return collections
.map((collection) => {
const collectionPath = collection.path ? posixifyPath(collection.path) : collection.path;
if (collectionPath && !path.isAbsolute(collectionPath)) {
if (collection.path && !path.isAbsolute(collection.path)) {
return {
...collection,
path: path.resolve(workspacePath, collectionPath)
path: path.resolve(workspacePath, collection.path)
};
}
return { ...collection, path: collectionPath };
return collection;
})
.filter((collection) => {
if (!collection.path) {
@@ -474,14 +427,13 @@ const getWorkspaceApiSpecs = (workspacePath) => {
const specs = config.specs || [];
return specs.map((spec) => {
const specPath = spec.path ? posixifyPath(spec.path) : spec.path;
if (specPath && !path.isAbsolute(specPath)) {
if (spec.path && !path.isAbsolute(spec.path)) {
return {
...spec,
path: path.join(workspacePath, specPath)
path: path.join(workspacePath, spec.path)
};
}
return { ...spec, path: specPath };
return spec;
});
};
@@ -503,7 +455,7 @@ const addApiSpecToWorkspace = async (workspacePath, apiSpec) => {
};
const existingIndex = config.specs.findIndex(
(a) => a.name === normalizedSpec.name || (a.path && posixifyPath(a.path) === normalizedSpec.path)
(a) => a.name === normalizedSpec.name || a.path === normalizedSpec.path
);
if (existingIndex >= 0) {
@@ -529,7 +481,7 @@ const removeApiSpecFromWorkspace = async (workspacePath, apiSpecPath) => {
let removedApiSpec = null;
config.specs = config.specs.filter((a) => {
const specPathFromYml = a.path ? posixifyPath(a.path) : a.path;
const specPathFromYml = a.path;
if (!specPathFromYml) return true;
const absoluteSpecPath = path.isAbsolute(specPathFromYml)
@@ -576,7 +528,6 @@ module.exports = {
updateWorkspaceDocs,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
reorderWorkspaceCollections,
getWorkspaceCollections,
getWorkspaceApiSpecs,
addApiSpecToWorkspace,

View File

@@ -1,76 +0,0 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const yaml = require('js-yaml');
const { reorderWorkspaceCollections } = require('../../src/utils/workspace-config');
const collection = (name, pathSegment) => ({ name, path: pathSegment });
describe('reorderWorkspaceCollections', () => {
let workspacePath;
/** Writes workspace.yml with the given collections (relative paths). */
const writeWorkspaceYml = (collections) => {
const content = [
'opencollection: 1.0.0',
'info:',
' name: Test',
' type: workspace',
'collections:',
...collections.flatMap((c) => [` - name: ${c.name}`, ` path: ${c.path}`]),
'specs: []',
'docs: \'\''
].join('\n');
fs.writeFileSync(path.join(workspacePath, 'workspace.yml'), content);
};
/** Returns collection paths (relative) in order as stored in workspace.yml. */
const getCollectionPathsFromYml = () => {
const raw = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8');
const config = yaml.load(raw);
return (config.collections || []).map((c) => c.path);
};
/** Resolves a relative collection path segment to an absolute path under the current workspace. */
const absPath = (relativePath) => path.resolve(workspacePath, relativePath);
beforeEach(() => {
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-ws-'));
});
afterEach(() => {
fs.rmSync(workspacePath, { recursive: true, force: true });
});
test('reorders collections to match given path list', async () => {
writeWorkspaceYml([
collection('API', 'collections/api'),
collection('Backend', 'collections/backend'),
collection('Frontend', 'collections/frontend')
]);
await reorderWorkspaceCollections(workspacePath, [
absPath('collections/frontend'),
absPath('collections/api'),
absPath('collections/backend')
]);
expect(getCollectionPathsFromYml()).toEqual(['collections/frontend', 'collections/api', 'collections/backend']);
});
test('deduplicates when reorder list contains duplicate paths', async () => {
writeWorkspaceYml([
collection('API', 'collections/api'),
collection('Backend', 'collections/backend')
]);
await reorderWorkspaceCollections(workspacePath, [
absPath('collections/api'),
absPath('collections/backend'),
absPath('collections/api'),
absPath('collections/api')
]);
expect(getCollectionPathsFromYml()).toEqual(['collections/api', 'collections/backend']);
});
});

View File

@@ -58,12 +58,12 @@ const parseCollection = (ymlString: string): ParsedCollection => {
// protobuf
if (oc.config?.protobuf) {
brunoConfig.protobuf = {
protoFiles: oc.config.protobuf.protoFiles?.map((protoFile: any) => ({
protofFiles: oc.config.protobuf.protoFiles?.map((protoFile: any) => ({
path: protoFile.path
})) || [],
importPaths: oc.config.protobuf.importPaths?.map((importPath: any) => ({
path: importPath.path,
enabled: importPath.disabled !== true
disabled: importPath.disabled || false
})) || []
};
}

View File

@@ -16,7 +16,7 @@ import type { Auth } from '@opencollection/types/common/auth';
const hasCollectionConfig = (brunoConfig: any): boolean => {
// protobuf
const hasProtobuf = (
brunoConfig.protobuf?.protoFiles?.length > 0
brunoConfig.protobuf?.protofFiles?.length > 0
|| brunoConfig.protobuf?.importPaths?.length > 0
);
@@ -77,25 +77,17 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
if (hasCollectionConfig(brunoConfig)) {
oc.config = {};
if (brunoConfig.protobuf?.protoFiles?.length || brunoConfig.protobuf?.importPaths?.length) {
oc.config.protobuf = {};
if (brunoConfig.protobuf.protoFiles?.length) {
oc.config.protobuf.protoFiles = brunoConfig.protobuf.protoFiles.map((protoFile: any): ProtoFileItem => ({
if (brunoConfig.protobuf?.protofFiles?.length) {
oc.config.protobuf = {
protoFiles: brunoConfig.protobuf.protofFiles.map((protoFile: any): ProtoFileItem => ({
type: 'file' as const,
path: protoFile.path
}));
}
if (brunoConfig.protobuf.importPaths?.length) {
oc.config.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((importPath: any): ProtoFileImportPath => {
const item: ProtoFileImportPath = { path: importPath.path };
if (importPath.enabled === false) {
item.disabled = true;
}
return item;
});
}
})),
importPaths: brunoConfig.protobuf.importPaths.map((importPath: any): ProtoFileImportPath => ({
path: importPath.path,
disabled: importPath.disabled
}))
};
}
// proxy - only write newer format

View File

@@ -92,8 +92,6 @@ class Bru {
};
// Holds variables that are marked as persistent by scripts
this.persistentEnvVariables = {};
// Holds credential IDs to be reset after script execution
this.oauth2CredentialsToReset = [];
this.runner = {
skipRequest: () => {
this.skipRequest = true;
@@ -277,24 +275,6 @@ class Bru {
return this.interpolate(this.oauth2CredentialVariables[key]);
}
resetOauth2Credential(credentialId) {
if (!credentialId || typeof credentialId !== 'string') {
throw new Error('credentialId must be a non-empty string');
}
if (!this.oauth2CredentialsToReset.includes(credentialId)) {
this.oauth2CredentialsToReset.push(credentialId);
}
// Remove matching credential variables so subsequent getOauth2CredentialVar() calls return undefined
const prefix = `$oauth2.${credentialId}.`;
for (const key of Object.keys(this.oauth2CredentialVariables)) {
if (key.startsWith(prefix)) {
delete this.oauth2CredentialVariables[key];
}
}
}
hasVar(key) {
return Object.hasOwn(this.runtimeVariables, key);
}

View File

@@ -125,13 +125,6 @@ class BrunoRequest {
this.req.headers = headers;
}
deleteHeaders(headers) {
headers.forEach((name) => {
delete this.headers[name];
delete this.req.headers[name];
});
}
getHeader(name) {
return this.req.headers[name];
}

View File

@@ -13,12 +13,7 @@ chai.use(function (chai, utils) {
// Custom assertion for checking if a variable is JSON
chai.Assertion.addProperty('json', function () {
const obj = this._obj;
// Use Object.prototype.toString instead of constructor check for cross-realm compatibility.
// Objects created inside Node's vm.createContext() have a different Object constructor,
// so obj.constructor === Object fails for objects passed via res.setBody() from scripts.
// Note: toString check is more permissive than constructor check — custom class instances
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj)
&& Object.prototype.toString.call(obj) === '[object Object]';
const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`);
});

View File

@@ -76,7 +76,6 @@ class ScriptRuntime {
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
@@ -194,7 +193,6 @@ class ScriptRuntime {
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,

View File

@@ -27,14 +27,13 @@ class TestRuntime {
collectionName
) {
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const promptVariables = request?.promptVariables || {};
const assertionResults = request?.assertionResults || [];
const certsAndProxyConfig = request?.certsAndProxyConfig;
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, certsAndProxyConfig);
const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables, certsAndProxyConfig);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -110,7 +109,6 @@ class TestRuntime {
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest
};

View File

@@ -89,12 +89,6 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getOauth2CredentialVar', getOauth2CredentialVar);
getOauth2CredentialVar.dispose();
let resetOauth2Credential = vm.newFunction('resetOauth2Credential', function (credentialId) {
bru.resetOauth2Credential(vm.dump(credentialId));
});
vm.setProp(bruObject, 'resetOauth2Credential', resetOauth2Credential);
resetOauth2Credential.dispose();
let setGlobalEnvVar = vm.newFunction('setGlobalEnvVar', function (key, value) {
bru.setGlobalEnvVar(vm.dump(key), vm.dump(value));
});

View File

@@ -102,12 +102,6 @@ const addBrunoRequestShimToContext = (vm, req) => {
vm.setProp(reqObject, 'setHeaders', setHeaders);
setHeaders.dispose();
let deleteHeaders = vm.newFunction('deleteHeaders', function (headers) {
req.deleteHeaders(vm.dump(headers));
});
vm.setProp(reqObject, 'deleteHeaders', deleteHeaders);
deleteHeaders.dispose();
let getHeader = vm.newFunction('getHeader', function (name) {
return marshallToVm(req.getHeader(vm.dump(name)), vm);
});
@@ -120,8 +114,8 @@ const addBrunoRequestShimToContext = (vm, req) => {
vm.setProp(reqObject, 'setHeader', setHeader);
setHeader.dispose();
let deleteHeader = vm.newFunction('deleteHeader', function (header) {
req.deleteHeader(vm.dump(header));
let deleteHeader = vm.newFunction('deleteHeader', function (name) {
req.deleteHeader(vm.dump(name));
});
vm.setProp(reqObject, 'deleteHeader', deleteHeader);
deleteHeader.dispose();

View File

@@ -58,27 +58,6 @@ const addBruShimToContext = (vm, __brunoTestResults) => {
globalThis.test = Test(__brunoTestResults);
`
);
// Register custom chai assertion for isJson (expect(...).to.be.json)
// The bundled chai only exposes { expect, assert } — no Assertion class.
// Access the prototype through an expect() instance instead.
vm.evalCode(
`
(function() {
var proto = Object.getPrototypeOf(expect(null));
Object.defineProperty(proto, 'json', {
get: function () {
var obj = this._obj;
var isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) &&
Object.prototype.toString.call(obj) === '[object Object]';
this.assert(isJson, 'expected #{this} to be JSON', 'expected #{this} not to be JSON');
return this;
},
configurable: true
});
})();
`
);
};
module.exports = addBruShimToContext;

View File

@@ -1,7 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
const TestRuntime = require('../src/runtime/test-runtime');
const ScriptRuntime = require('../src/runtime/script-runtime');
const AssertRuntime = require('../src/runtime/assert-runtime');
const Bru = require('../src/bru');
const VarsRuntime = require('../src/runtime/vars-runtime');
@@ -259,87 +258,4 @@ describe('runtime', () => {
expect(result.runtimeVariables.title).toBe('{{$randomFirstName}}');
});
});
describe('assert-runtime', () => {
const baseRequest = {
method: 'GET',
url: 'http://localhost:3000/',
headers: {},
data: undefined
};
const makeResponse = (data) => ({
status: 200,
statusText: 'OK',
data,
headers: {}
});
const runAssertions = (assertions, response, runtime = 'nodevm') => {
const assertRuntime = new AssertRuntime({ runtime });
return assertRuntime.runAssertions(assertions, { ...baseRequest }, response, {}, {}, process.env);
};
describe('isJson', () => {
it('should pass for a plain object', () => {
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
makeResponse({ id: 1, name: 'test' })
);
expect(results[0].status).toBe('pass');
});
it('should pass for a nested object', () => {
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
makeResponse({ user: { id: 1, tags: ['a', 'b'] } })
);
expect(results[0].status).toBe('pass');
});
it('should pass for objects from a different realm (e.g. after res.setBody in node-vm)', async () => {
const response = makeResponse({ id: 1, name: 'original' });
// res.setBody() inside node-vm creates a cross-realm object whose
// constructor is the VM's Object, not the host's Object
const scriptRuntime = new ScriptRuntime({ runtime: 'nodevm' });
await scriptRuntime.runResponseScript(
`res.setBody({ id: 2, name: 'updated' });`,
{ ...baseRequest },
response,
{}, {}, '.', null, process.env
);
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
response
);
expect(results[0].status).toBe('pass');
});
it('should fail for an array', () => {
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
makeResponse([1, 2, 3])
);
expect(results[0].status).toBe('fail');
});
it('should fail for a string', () => {
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
makeResponse('hello')
);
expect(results[0].status).toBe('fail');
});
it('should fail for null', () => {
const results = runAssertions(
[{ name: 'res.body', value: 'isJson', enabled: true }],
makeResponse(null)
);
expect(results[0].status).toBe('fail');
});
});
});
});

View File

@@ -34,8 +34,7 @@
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",
"ws": "^8.18.3",
"shell-env": "^4.0.1"
"ws": "^8.18.3"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",

View File

@@ -39,6 +39,6 @@ module.exports = [
typescript({ tsconfig: './tsconfig.json' }),
terser()
],
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env'].includes(id)
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug'].includes(id)
}
];

View File

@@ -8,7 +8,6 @@ export { transformProxyConfig } from './utils/proxy-util';
export { default as createVaultClient, VaultError } from './utils/node-vault';
export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault';
export { getHttpHttpsAgents } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export * as scripting from './scripting';

View File

@@ -1,33 +0,0 @@
/**
* Shell Environment Utility
*
* Fetches environment variables from the user's shell configuration files (e.g., .zshenv, .bashrc)
*/
const fetchShellEnv = async (): Promise<Record<string, string>> => {
// Windows handles environment variables differently - skip
if (process.platform === 'win32') {
return {};
}
try {
// shell-env is ESM-only, so we use dynamic import
const { shellEnv } = await import('shell-env');
const env = await shellEnv();
return env;
} catch (error) {
return {};
}
};
/**
* Initializes process.env with shell environment variables.
* Should be called early in the app startup.
*
* @returns The fetched shell environment variables
*/
export const initializeShellEnv = async (): Promise<Record<string, string>> => {
const shellEnvVars = await fetchShellEnv();
Object.assign(process.env, shellEnvVars);
return shellEnvVars;
};

View File

@@ -1,32 +0,0 @@
meta {
name: deleteHeaders
type: http
seq: 13
}
get {
url: {{host}}/ping
body: none
auth: none
}
headers {
X-Frame-Options: 1
Content-Type: application/json
}
assert {
res.status: eq 200
res.body: eq pong
}
script:pre-request {
req.deleteHeaders(['X-Frame-Options']);
}
tests {
test("req.deleteHeaders(names)", function() {
const h = req.getHeaders();
expect(h["x-frame-options"]).to.be.undefined;
});
}

View File

@@ -1,36 +0,0 @@
meta {
name: isJson after setBody
type: http
seq: 2
}
post {
url: {{host}}/api/echo/json
body: json
auth: none
}
body:json {
{
"hello": "bruno"
}
}
assert {
res.status: eq 200
res.body: isJson
}
script:post-response {
res.setBody({ id: 1, name: "updated", nested: { key: "value" } });
}
tests {
test("res.body should be json after setBody with object", function() {
const body = res.getBody();
expect(body).to.be.json;
expect(body.id).to.eql(1);
expect(body.name).to.eql("updated");
expect(body.nested.key).to.eql("value");
});
}

View File

@@ -1,4 +1,4 @@
import { test as baseTest, BrowserContext, ElectronApplication, Page, TestInfo } from '@playwright/test';
import { test as baseTest, BrowserContext, ElectronApplication, Page } from '@playwright/test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
@@ -25,89 +25,19 @@ async function recursiveCopy(src: string, dest: string) {
}
}
const TRACING_OPTIONS = { screenshots: true, snapshots: true, sources: true };
function isTracingEnabled(testInfo: TestInfo): boolean {
return !!(testInfo as any)._tracing.traceOptions();
}
async function usePageWithTracing(
context: BrowserContext,
page: Page,
testInfo: TestInfo,
use: (page: Page) => Promise<void>,
options: { initTracing?: boolean; useChunks?: boolean } = {}
) {
const { initTracing = false, useChunks = true } = options;
if (!isTracingEnabled(testInfo)) {
await use(page);
return;
}
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
if (initTracing) {
try {
await context.tracing.start(TRACING_OPTIONS);
} catch (e) { }
}
if (useChunks) {
await context.tracing.startChunk();
await use(page);
try { await context.tracing.stopChunk({ path: tracePath }); } catch { }
} else {
await use(page);
try { await context.tracing.stop({ path: tracePath }); } catch { }
}
try { await testInfo.attach('trace', { path: tracePath }); } catch { }
}
/**
* Gracefully close an Electron app by telling it to exit with code 0.
* This avoids the macOS "quit unexpectedly" crash dialog that appears when
* app.context().close() kills subprocesses (renderer/GPU) abruptly before
* the main process can shut down cleanly.
*
* Emits 'before-quit' first so cleanup handlers run (e.g., saving cookies to disk),
* since app.exit() bypasses all lifecycle events.
*/
export async function closeElectronApp(app: ElectronApplication) {
try {
await app.evaluate(async ({ app }) => {
app.emit('before-quit');
// Add a delay to ensure the app is fully closed
await new Promise((resolve) => setTimeout(resolve, 250));
app.exit(0);
});
} catch {
// Expected: process exited before the CDP response was sent
}
try {
await app.close();
} catch {
// Process already exited
}
}
export const test = baseTest.extend<
{
context: BrowserContext;
page: Page;
newPage: Page;
pageWithUserData: Page;
collectionFixturePath: string | null;
restartApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
},
{
createTmpDir: (tag?: string) => Promise<string>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string>; templateVars?: Record<string, string> }) => Promise<ElectronApplication>;
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
electronApp: ElectronApplication;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; testFile?: string; userDataPath?: string; dotEnv?: Record<string, string>; templateVars?: Record<string, string>; closePrevious?: boolean }) => Promise<ElectronApplication>;
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; testFile?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
}
>({
createTmpDir: [
@@ -125,27 +55,10 @@ export const test = baseTest.extend<
{ scope: 'worker' }
],
collectionFixturePath: async ({ createTmpDir }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const fixturesDir = path.join(testDir, 'fixtures');
// fixtures/collections — multiple named collections (subdirs with bruno.json/opencollection.yml)
// fixtures/collection — single collection (single dir with bruno.json/opencollection.yml)
const srcPath = [path.join(fixturesDir, 'collections'), path.join(fixturesDir, 'collection')]
.find((p) => fs.existsSync(p));
if (srcPath) {
const tmpDir = await createTmpDir(path.basename(srcPath));
await fs.promises.cp(srcPath, tmpDir, { recursive: true });
await use(tmpDir);
} else {
await use(null);
}
},
launchElectronApp: [
async ({ playwright, createTmpDir }, use, workerInfo) => {
const apps: ElectronApplication[] = [];
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath, dotEnv = {}, templateVars = {} } = {}) => {
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath, dotEnv = {} } = {}) => {
const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata'));
// Ensure dir exists when caller supplies their own path
@@ -154,9 +67,8 @@ export const test = baseTest.extend<
}
if (initUserDataPath) {
const replacements: Record<string, string> = {
projectRoot: path.posix.join(__dirname, '..'),
...templateVars
const replacements = {
projectRoot: path.posix.join(__dirname, '..')
};
for (const file of await fs.promises.readdir(initUserDataPath)) {
@@ -201,7 +113,8 @@ export const test = baseTest.extend<
return app;
});
for (const app of apps) {
await closeElectronApp(app);
await app.context().close();
await app.close();
}
},
{ scope: 'worker' }
@@ -217,9 +130,10 @@ export const test = baseTest.extend<
context: async ({ electronApp }, use, testInfo) => {
const context = await electronApp.context();
if (isTracingEnabled(testInfo)) {
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
try {
await context.tracing.start(TRACING_OPTIONS);
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) { }
}
await use(context);
@@ -227,39 +141,43 @@ export const test = baseTest.extend<
page: async ({ electronApp, context }, use, testInfo) => {
const page = await electronApp.firstWindow();
await usePageWithTracing(context, page, testInfo, use);
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
newPage: async ({ launchElectronApp }, use, testInfo) => {
const app = await launchElectronApp();
const context = await app.context();
const page = await app.firstWindow();
await usePageWithTracing(context, page, testInfo, use, { initTracing: true, useChunks: false });
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await use(page);
await context.tracing.stop({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
},
reuseOrLaunchElectronApp: [
async ({ launchElectronApp }, use, testInfo) => {
const apps: Record<string, ElectronApplication> = {};
await use(async ({ initUserDataPath, testFile, userDataPath, dotEnv = {}, templateVars = {}, closePrevious = false } = {}) => {
await use(async ({ initUserDataPath, testFile, userDataPath, dotEnv = {} } = {}) => {
const key = testFile || userDataPath || initUserDataPath;
if (key && apps[key]) {
if (closePrevious) {
await closeElectronApp(apps[key]);
delete apps[key];
} else {
return apps[key];
}
return apps[key];
}
// Close other cached apps to prevent resource accumulation across test files
for (const existingKey of Object.keys(apps)) {
if (existingKey !== key) {
await closeElectronApp(apps[existingKey]);
delete apps[existingKey];
}
}
const app = await launchElectronApp({ initUserDataPath, userDataPath, dotEnv, templateVars });
const app = await launchElectronApp({ initUserDataPath, userDataPath, dotEnv });
if (key) {
apps[key] = app;
}
@@ -269,39 +187,33 @@ export const test = baseTest.extend<
{ scope: 'worker' }
],
restartApp: async ({ reuseOrLaunchElectronApp, createTmpDir, collectionFixturePath }, use, testInfo) => {
restartApp: async ({ launchElectronApp }, use, testInfo) => {
const appInstances: Array<{ app: ElectronApplication; initUserDataPath?: string }> = [];
await use(async ({ initUserDataPath } = {}) => {
// Get the test directory and check for init-user-data folder
const testDir = path.dirname(testInfo.file);
const defaultInitUserDataPath = path.join(testDir, 'init-user-data');
let srcUserDataPath = initUserDataPath;
if (!srcUserDataPath) {
// Use provided initUserDataPath, or check if default path exists, or use undefined
let userDataPath = initUserDataPath;
if (!userDataPath) {
const hasInitUserData = await fs.promises.stat(defaultInitUserDataPath).catch(() => false);
srcUserDataPath = hasInitUserData ? defaultInitUserDataPath : undefined;
userDataPath = hasInitUserData ? defaultInitUserDataPath : undefined;
}
// Copy init-user-data to a fresh tmp dir (same as pageWithUserData)
const tmpAppDataDir = await createTmpDir();
if (srcUserDataPath) {
await recursiveCopy(srcUserDataPath, tmpAppDataDir);
}
const templateVars: Record<string, string> = {};
if (collectionFixturePath) {
templateVars.collectionPath = collectionFixturePath;
}
// Close the previous app (from pageWithUserData) before launching a new one
return await reuseOrLaunchElectronApp({
initUserDataPath: tmpAppDataDir,
testFile: testInfo.file,
templateVars,
closePrevious: true
});
const app = await launchElectronApp({ initUserDataPath: userDataPath });
appInstances.push({ app, initUserDataPath: userDataPath });
return app;
});
// Clean up all app instances
for (const { app } of appInstances) {
await app.context().close();
await app.close();
}
},
pageWithUserData: async ({ reuseOrLaunchElectronApp, createTmpDir, collectionFixturePath }, use, testInfo) => {
pageWithUserData: async ({ reuseOrLaunchElectronApp, createTmpDir }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const initUserDataPath = path.join(testDir, 'init-user-data');
@@ -315,19 +227,23 @@ export const test = baseTest.extend<
throw err;
}
const templateVars: Record<string, string> = {};
if (collectionFixturePath) {
templateVars.collectionPath = collectionFixturePath;
}
const app = await reuseOrLaunchElectronApp({ initUserDataPath: tmpAppDataDir, testFile: testInfo.file, templateVars });
const app = await reuseOrLaunchElectronApp({ initUserDataPath: tmpAppDataDir, testFile: testInfo.file });
const context = await app.context();
const page = await app.firstWindow();
// Wait for app to be ready
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await usePageWithTracing(context, page, testInfo, use, { initTracing: true });
const tracingOptions = (testInfo as any)._tracing.traceOptions();
if (tracingOptions) {
const tracePath = testInfo.outputPath(`trace-${testInfo.testId}.zip`);
try {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
} catch (e) { }
await context.tracing.startChunk();
await use(page);
await context.tracing.stopChunk({ path: tracePath });
await testInfo.attach('trace', { path: tracePath });
} else {
await use(page);
}
}
});

View File

@@ -10,6 +10,19 @@ test.describe('Create GraphQL Requests', () => {
});
test.afterAll(async ({ pageWithUserData: page }) => {
// Clean up Root GraphQL Request
await locators.sidebar.request('Root GraphQL Request').hover();
await locators.actions.collectionItemActions('Root GraphQL Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up Folder GraphQL Request
await locators.sidebar.request('Folder GraphQL Request').hover();
await locators.actions.collectionItemActions('Folder GraphQL Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up collection
await closeAllCollections(page);
});

View File

@@ -10,6 +10,21 @@ test.describe('Create gRPC Requests', () => {
});
test.afterAll(async ({ pageWithUserData: page }) => {
const locators = buildCommonLocators(page);
// Clean up Root gRPC Request
await locators.sidebar.request('Root gRPC Request').hover();
await locators.actions.collectionItemActions('Root gRPC Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up Folder gRPC Request
await locators.sidebar.request('Folder gRPC Request').hover();
await locators.actions.collectionItemActions('Folder gRPC Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up collection
await closeAllCollections(page);
});

View File

@@ -10,6 +10,21 @@ test.describe('Create HTTP Requests', () => {
});
test.afterAll(async ({ pageWithUserData: page }) => {
const locators = buildCommonLocators(page);
// Clean up Root HTTP Request
await locators.sidebar.request('Root HTTP Request').hover();
await locators.actions.collectionItemActions('Root HTTP Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up Folder HTTP Request
await locators.sidebar.request('Folder HTTP Request').hover();
await locators.actions.collectionItemActions('Folder HTTP Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up collection
await closeAllCollections(page);
});

View File

@@ -1,7 +1,7 @@
{
"collections": [
{
"path": "{{collectionPath}}",
"path": "{{projectRoot}}/tests/collection/create-requests/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}

View File

@@ -1,6 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{collectionPath}}"
"{{projectRoot}}/tests/collection/create-requests/fixtures/collection"
]
}

View File

@@ -10,6 +10,21 @@ test.describe('Create WebSocket Requests', () => {
});
test.afterAll(async ({ pageWithUserData: page }) => {
const locators = buildCommonLocators(page);
// Clean up Folder WebSocket Request
await locators.sidebar.request('Folder WebSocket Request').hover();
await locators.actions.collectionItemActions('Folder WebSocket Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up Root WebSocket Request
await locators.sidebar.request('Root WebSocket Request').hover();
await locators.actions.collectionItemActions('Root WebSocket Request').click();
await locators.dropdown.item('Delete').click();
await locators.modal.button('Delete').click();
// Clean up collection
await closeAllCollections(page);
});

View File

@@ -23,11 +23,11 @@ test.describe('Draft values are used in requests', () => {
const nameEditor = headerRow.locator('.CodeMirror').first();
await nameEditor.click();
await headerRow.locator('textarea').first().fill('X-Draft-Header');
await page.keyboard.type('X-Draft-Header');
const valueEditor = headerRow.locator('.CodeMirror').nth(1);
await valueEditor.click();
await headerRow.locator('textarea').nth(1).fill('draft-value-123');
await page.keyboard.type('draft-value-123');
// Verify draft indicator appears (header is not saved yet)
const collectionTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) });
@@ -51,11 +51,11 @@ test.describe('Draft values are used in requests', () => {
const folderNameEditor = folderHeaderRow.locator('.CodeMirror').first();
await folderNameEditor.click();
await folderHeaderRow.locator('textarea').first().fill('X-Folder-Draft-Header');
await page.keyboard.type('X-Folder-Draft-Header');
const folderValueEditor = folderHeaderRow.locator('.CodeMirror').nth(1);
await folderValueEditor.click();
await folderHeaderRow.locator('textarea').nth(1).fill('folder-draft-value-123');
await page.keyboard.type('folder-draft-value-123');
// Create a request in the collection
// Create a new request via collection menu
@@ -122,7 +122,7 @@ test.describe('Draft values are used in requests', () => {
// Create a new request from collection menu
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
await collection.hover();
await collection.locator('.collection-actions .icon').click({ force: true });
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByTestId('request-name').fill('Test Request');
await page.getByTestId('new-request-url').locator('.CodeMirror').click();

View File

@@ -29,7 +29,7 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
// Add a request to the folder to make it more realistic
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).hover();
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).locator('.menu-icon').click({ force: true });
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).locator('.menu-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request-in-folder');
await page.locator('#new-request-url .CodeMirror').click();
@@ -126,7 +126,7 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
// Add a request to the folder to make it more realistic
await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).hover();
await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).locator('.menu-icon').click({ force: true });
await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).locator('.menu-icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('http-request');
await page.locator('#new-request-url .CodeMirror').click();

View File

@@ -94,8 +94,8 @@ test.describe('Cross-Collection Drag and Drop', () => {
await expect(page.getByText(/Error: Cannot copy.*already exists/i)).toBeVisible();
// source and target collection request should remain unchanged
await expect(sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first()).toBeVisible();
await expect(sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first()).toBeVisible();
await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible();
});
});

View File

@@ -99,8 +99,7 @@ test.describe('Tag persistence', () => {
await locators.tags.input().fill('smoke');
await locators.tags.input().press('Enter');
await expect(locators.tags.item('smoke')).toBeVisible();
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
await page.keyboard.press(saveShortcut);
await page.keyboard.press('Meta+s');
// Create another folder
await locators.sidebar.collectionRow('test-collection').hover();

View File

@@ -1,4 +1,4 @@
import { test, expect, closeElectronApp } from '../../playwright';
import { test, expect } from '../../playwright';
test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => {
// Create a temporary user-data directory so we control where the cookies store file is written.
@@ -26,7 +26,7 @@ test('should persist cookies across app restarts', async ({ createTmpDir, launch
await expect(page1.getByText('example.com')).toBeVisible();
await closeElectronApp(app1);
await app1.close();
// Second launch verify the cookie was persisted and re-loaded
const app2 = await launchElectronApp({ userDataPath });
@@ -39,5 +39,5 @@ test('should persist cookies across app restarts', async ({ createTmpDir, launch
// The domain we added earlier should still be present.
await expect(page2.getByText('example.com')).toBeVisible();
await closeElectronApp(app2);
await app2.close();
});

View File

@@ -1,4 +1,4 @@
import { test, expect, closeElectronApp } from '../../playwright';
import { test, expect } from '../../playwright';
import * as path from 'path';
import * as fs from 'fs/promises';
@@ -24,7 +24,7 @@ test('should handle corrupted passkey and still display saved cookie list', asyn
await expect(page1.getByText('example.com')).toBeVisible();
await closeElectronApp(app1);
await app1.close();
// 2. Corrupt the encryptedPasskey in cookies.json
const cookiesFilePath = path.join(userDataPath, 'cookies.json');
@@ -43,5 +43,5 @@ test('should handle corrupted passkey and still display saved cookie list', asyn
// The domain row should still be visible (even if cookie values are blank).
await expect(page2.getByText('example.com')).toBeVisible();
await closeElectronApp(app2);
await app2.close();
});

View File

@@ -33,8 +33,7 @@ test.describe('EditableTable - Focus and Placeholder', () => {
await expect(nameInput).toBeFocused();
// Save the request
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
await page.keyboard.press(saveShortcut);
await page.keyboard.press('Meta+s');
// Wait for save toast
await expect(page.getByText('Request saved successfully').last()).toBeVisible();

View File

@@ -1,8 +1,14 @@
import { test, expect, closeElectronApp } from '../../../playwright';
import { test, expect } from '../../../playwright';
import fs from 'fs';
import path from 'path';
import { sendRequest } from '../../utils/page';
test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
test('set env var with persist using script', async ({ pageWithUserData: page, restartApp }) => {
// Keep a copy of the original Stage.bru file
const originalStageBruPath = path.join(__dirname, 'fixtures/collection/environments/Stage.bru');
const originalStageBruContent = fs.readFileSync(originalStageBruPath, 'utf8');
// Select the collection and request
await page.locator('#sidebar-collection-name').click();
await page.getByText('api-setEnvVar-with-persist', { exact: true }).click();
@@ -32,7 +38,7 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
// we restart the app to confirm that the environment variable is persisted
const newApp = await restartApp();
@@ -53,8 +59,10 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
await expect(newPage.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
await newEnvTab.hover();
await newEnvTab.getByTestId('request-tab-close-icon').click({ force: true });
await newEnvTab.getByTestId('request-tab-close-icon').click();
await closeElectronApp(newApp);
// Restore the original Stage.bru file
fs.writeFileSync(originalStageBruPath, originalStageBruContent);
await newPage.close();
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect, closeElectronApp } from '../../../playwright';
import { test, expect } from '../../../playwright';
import { sendRequest } from '../../utils/page';
test.describe.serial('bru.setEnvVar(name, value)', () => {
@@ -28,7 +28,7 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
// we restart the app to confirm that the environment variable is not persisted
const newApp = await restartApp();
@@ -48,7 +48,7 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
await expect(newPage.locator('.table-container tbody')).not.toContainText('token');
await newEnvTab.hover();
await newEnvTab.getByTestId('request-tab-close-icon').click({ force: true });
await closeElectronApp(newApp);
await newEnvTab.getByTestId('request-tab-close-icon').click();
await newPage.close();
});
});

View File

@@ -1,7 +1,7 @@
{
"collections": [
{
"path": "{{collectionPath}}",
"path": "{{projectRoot}}/tests/environments/api-setEnvVar/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}

View File

@@ -1,6 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{collectionPath}}"
"{{projectRoot}}/tests/environments/api-setEnvVar/fixtures/collection"
]
}

View File

@@ -27,7 +27,7 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
}
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
}
} catch (error) {
// Ignore cleanup errors to avoid masking test failures
@@ -35,7 +35,7 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
}
});
test('should persist multiple environment variables from different requests', async ({ pageWithUserData: page, collectionFixturePath }) => {
test('should persist multiple environment variables from different requests', async ({ pageWithUserData: page }) => {
await test.step('Select collection', async () => {
await page.locator('#sidebar-collection-name').click();
// The collection name should be 'collection' based on the test setup
@@ -85,12 +85,12 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
await expect(page.getByRole('row', { name: 'multiple-persist-vars-key2' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'value2' }).getByRole('cell').nth(2)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Verify variables are persisted to file', async () => {
// Check that the variables are written to the Stage.bru file
const stageBruPath = path.join(collectionFixturePath!, 'environments', 'Stage.bru');
const stageBruPath = path.join(__dirname, 'fixtures/collection/environments/Stage.bru');
const stageBruContent = fs.readFileSync(stageBruPath, 'utf8');
// Both variables should be present in the file

View File

@@ -35,6 +35,6 @@ test.describe('Collection Environment Configuration Selection Tests', () => {
await expect(activeEnvItem).toContainText('prod');
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
});

View File

@@ -1,5 +1,5 @@
{
"maximized": false,
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/environments/collection-env-config-selection/collection"
]

View File

@@ -1,5 +1,5 @@
{
"maximized": false,
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/environments/global-env-config-selection/collection"
]

View File

@@ -28,6 +28,6 @@ test.describe('Global Environment Configuration Selection Tests', () => {
await expect(activeEnvItem).toContainText(currentEnvName);
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
});

View File

@@ -1,5 +1,5 @@
{
"maximized": false,
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/tests/environments/global-env-config-selection/collection"
]

View File

@@ -47,7 +47,7 @@ test.describe.serial('Collection Environment Import Tests', () => {
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {
@@ -128,7 +128,7 @@ test.describe.serial('Collection Environment Import Tests', () => {
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
await test.step('Clean up after test', async () => {

View File

@@ -62,7 +62,7 @@ test.describe.serial('Global Environment Import Tests', () => {
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
});
@@ -145,7 +145,7 @@ test.describe.serial('Global Environment Import Tests', () => {
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
});
});
});

View File

@@ -27,7 +27,6 @@ test.describe('Collection Environment Import Tests', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('collection-env-import-test'));
await locationModal.getByRole('button', { name: 'Import' }).click();
await locationModal.waitFor({ state: 'hidden' });
await expect(
page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })).toBeVisible({ timeout: 10000 });
@@ -62,7 +61,7 @@ test.describe('Collection Environment Import Tests', () => {
await expect(page.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(page.locator('input[name="5.secret"]')).toBeChecked();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
await page.locator('.collection-item-name').first().click();
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');

View File

@@ -1,6 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{collectionPath}}"
"{{projectRoot}}/tests/environments/import-environment/env-color-import/fixtures/collection"
]
}

View File

@@ -57,7 +57,7 @@ test.describe('Global Environment Import Tests', () => {
await expect(variablesTable.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
await expect(variablesTable.locator('input[name="5.secret"]')).toBeChecked();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
await envTab.getByTestId('request-tab-close-icon').click();
await page.locator('#collection-environment-test-collection .collection-item-name').first().click();
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');

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