mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
38 Commits
chore/upda
...
feat/ssl-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b74b76cc9a | ||
|
|
f070812845 | ||
|
|
adf5721ae0 | ||
|
|
bad1a02116 | ||
|
|
070c840e52 | ||
|
|
41f3519dcc | ||
|
|
c4c0576660 | ||
|
|
594fc30f9f | ||
|
|
8b08ba1ee9 | ||
|
|
3619448d55 | ||
|
|
b29bdc1e97 | ||
|
|
05bbb54df2 | ||
|
|
795fb08d1f | ||
|
|
0f05808886 | ||
|
|
592dd7d9e9 | ||
|
|
a5ff9cf144 | ||
|
|
93600b5da8 | ||
|
|
0f1febc1fe | ||
|
|
296612dcbc | ||
|
|
3e88cd6759 | ||
|
|
37d1b3c5f9 | ||
|
|
15c86f8e6b | ||
|
|
14c66bc42f | ||
|
|
f5a53319e0 | ||
|
|
61a260f71c | ||
|
|
c6f3007dbf | ||
|
|
8605810747 | ||
|
|
c2bad2e2c8 | ||
|
|
dbc1d11e23 | ||
|
|
9df4b04ae8 | ||
|
|
f51a7b2ded | ||
|
|
e2d3b4dbe8 | ||
|
|
28c4e24e2e | ||
|
|
9cbc58df70 | ||
|
|
0b7cd0e540 | ||
|
|
17c3dc0e2b | ||
|
|
75c3ab8032 | ||
|
|
6d86c76b21 |
@@ -23,6 +23,19 @@ reviews:
|
||||
drafts: false
|
||||
base_branches: ['main', 'release/*']
|
||||
path_instructions:
|
||||
- path: '**/*'
|
||||
instructions: |
|
||||
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
|
||||
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
|
||||
- Never assume case-sensitive or case-insensitive filesystems
|
||||
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
|
||||
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
|
||||
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
|
||||
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
|
||||
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
|
||||
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
|
||||
- Use `os.tmpdir()` instead of hardcoding `/tmp`
|
||||
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
|
||||
- path: 'tests/**/**.*'
|
||||
instructions: |
|
||||
Review the following e2e test code written using the Playwright test library. Ensure that:
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"api/*": ["src/api/*"],
|
||||
"pageComponents/*": ["src/pageComponents/*"],
|
||||
"providers/*": ["src/providers/*"],
|
||||
"utils/*": ["src/utils/*"],
|
||||
"store/*": ["src/store/*"]
|
||||
"utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
import { focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
@@ -150,9 +151,20 @@ const AppTitleBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
const handleCreateWorkspace = useCallback(async () => {
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
if (!defaultLocation) {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
}, [preferences, dispatch]);
|
||||
|
||||
const handleManageWorkspaces = () => {
|
||||
dispatch(showManageWorkspacePage());
|
||||
@@ -240,7 +252,7 @@ const AppTitleBar = () => {
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = collection.isLoading;
|
||||
|
||||
const totalRequestsInCollection = useMemo(
|
||||
() => getTotalRequestCountInCollection(collection),
|
||||
[collection.items]
|
||||
);
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||
|
||||
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
|
||||
<div className="font-medium">Requests</div>
|
||||
<div className="mt-1 text-muted">
|
||||
{
|
||||
isCollectionLoading
|
||||
? 'Loading requests...'
|
||||
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import get from 'lodash/get';
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortWorkspaces } from 'utils/workspaces';
|
||||
|
||||
@@ -59,6 +60,21 @@ const ManageWorkspace = () => {
|
||||
setDeleteWorkspaceModal({ open: true, workspace });
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
const defaultLocation = get(preferences, 'general.defaultLocation', '');
|
||||
if (!defaultLocation) {
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
|
||||
toast.success('Workspace created!');
|
||||
} catch (error) {
|
||||
toast.error(error?.message || 'Failed to create workspace');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{createWorkspaceModalOpen && (
|
||||
@@ -86,7 +102,7 @@ const ManageWorkspace = () => {
|
||||
</div>
|
||||
<span className="header-title">Manage Workspace</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
|
||||
<Button size="sm" onClick={handleCreateWorkspace} icon={<IconPlus size={14} strokeWidth={2} />}>
|
||||
Create Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,66 +2,12 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.cache-stats {
|
||||
padding: 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.purge-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,87 +1,120 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
savePreferences,
|
||||
clearHttpHttpsAgentCache
|
||||
} from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import debounce from 'lodash/debounce';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const cacheSchema = Yup.object().shape({
|
||||
sslSession: Yup.object({
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
const Cache = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
|
||||
setStats(cacheStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache stats:', error);
|
||||
setStats({ error: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const handleSave = useCallback(
|
||||
(newCachePreferences) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
cache: newCachePreferences
|
||||
})
|
||||
).catch(() => toast.error('Failed to update cache preferences'));
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
sslSession: {
|
||||
enabled: get(preferences, 'cache.sslSession.enabled', false)
|
||||
}
|
||||
},
|
||||
validationSchema: cacheSchema,
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
|
||||
handleSave(newPreferences);
|
||||
} catch (error) {
|
||||
console.error('Cache preferences validation error:', error.message);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
});
|
||||
|
||||
const debouncedSave = useCallback(
|
||||
debounce((values) => {
|
||||
cacheSchema
|
||||
.validate(values, { abortEarly: true })
|
||||
.then((validatedValues) => handleSaveRef.current(validatedValues))
|
||||
.catch(() => {});
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
const handlePurgeCache = async () => {
|
||||
setPurging(true);
|
||||
try {
|
||||
const result = await window.ipcRenderer.invoke('renderer:purge-cache');
|
||||
if (result.success) {
|
||||
toast.success('Cache purged successfully');
|
||||
await fetchStats();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to purge cache');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error purging cache:', error);
|
||||
toast.error('Failed to purge cache');
|
||||
} finally {
|
||||
setPurging(false);
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.cancel();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
|
||||
const handleAgentCachingChange = (e) => {
|
||||
formik.handleChange(e);
|
||||
// Immediately evict all cached agents when caching is disabled
|
||||
if (!e.target.checked) {
|
||||
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetCache = () => {
|
||||
dispatch(clearHttpHttpsAgentCache())
|
||||
.then(() => toast.success('ssl session cache cleared'))
|
||||
.catch(() => toast.error('Failed to clear ssl session cache'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-title">Collection Cache</div>
|
||||
<p className="description mb-4">
|
||||
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
|
||||
</p>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
|
||||
|
||||
<div className="cache-stats">
|
||||
{loading ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Loading...</span>
|
||||
</div>
|
||||
) : stats?.error ? (
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Error: {stats.error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Collections</span>
|
||||
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cached Files</span>
|
||||
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Cache Version</span>
|
||||
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center my-2">
|
||||
<input
|
||||
id="sslSession.enabled"
|
||||
type="checkbox"
|
||||
name="sslSession.enabled"
|
||||
checked={formik.values.sslSession.enabled}
|
||||
onChange={handleAgentCachingChange}
|
||||
className="mousetrap mr-0"
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
|
||||
Enable SSL session caching
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs mt-1 ml-6 opacity-70">
|
||||
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
|
||||
request.
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="purge-button"
|
||||
onClick={handlePurgeCache}
|
||||
disabled={purging || loading}
|
||||
>
|
||||
{purging ? 'Purging...' : 'Purge Cache'}
|
||||
</button>
|
||||
<div className="mt-6">
|
||||
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,9 +20,9 @@ import Proxy from './ProxySettings';
|
||||
import Display from './Display';
|
||||
import Keybindings from './Keybindings';
|
||||
import Beta from './Beta';
|
||||
import Cache from './Cache';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Cache from './Cache/index';
|
||||
|
||||
const Preferences = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -64,13 +64,13 @@ const Preferences = () => {
|
||||
return <Beta />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
|
||||
case 'cache': {
|
||||
return <Cache />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconUpload
|
||||
} from '@tabler/icons';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
@@ -53,6 +54,21 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
|
||||
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
|
||||
|
||||
// Auto-enter rename mode when workspace is newly created
|
||||
useEffect(() => {
|
||||
if (isScratchCollection && currentWorkspace?.isNewlyCreated) {
|
||||
dispatch(updateWorkspace({ uid: currentWorkspace.uid, isNewlyCreated: false }));
|
||||
setIsRenamingWorkspace(true);
|
||||
setWorkspaceNameInput(currentWorkspace.name || '');
|
||||
setWorkspaceNameError('');
|
||||
const timer = setTimeout(() => {
|
||||
workspaceNameInputRef.current?.focus();
|
||||
workspaceNameInputRef.current?.select();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
|
||||
|
||||
const handleCancelWorkspaceRename = useCallback(() => {
|
||||
setIsRenamingWorkspace(false);
|
||||
setWorkspaceNameInput('');
|
||||
|
||||
@@ -67,7 +67,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const isLoading = collection.isLoading;
|
||||
const collectionRef = useRef(null);
|
||||
const itemCount = collection.items?.length || 0;
|
||||
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useTheme } from '../../../../providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
import styled from 'styled-components';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const LinkStyle = styled.span`
|
||||
color: ${(props) => props.theme['text-link']};
|
||||
`;
|
||||
|
||||
const CreateOrOpenCollection = () => {
|
||||
const CreateOrOpenCollection = ({ onCreateClick }) => {
|
||||
const { theme } = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch(
|
||||
@@ -32,7 +26,7 @@ const CreateOrOpenCollection = () => {
|
||||
<LinkStyle
|
||||
className="underline text-link cursor-pointer"
|
||||
theme={theme}
|
||||
onClick={() => setCreateCollectionModalOpen(true)}
|
||||
onClick={onCreateClick}
|
||||
>
|
||||
Create
|
||||
</LinkStyle>
|
||||
@@ -45,12 +39,6 @@ const CreateOrOpenCollection = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="px-2 mt-4">
|
||||
{createCollectionModalOpen ? (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-center">
|
||||
<div>No collections found.</div>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.inline-collection-creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 1.6rem;
|
||||
padding-left: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-collection-input {
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cog-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconCheck, IconX, IconSettings } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import toast from 'react-hot-toast';
|
||||
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
|
||||
import { multiLineMsg } from 'utils/common';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const InlineCollectionCreator = ({ onComplete, onCancel, onOpenAdvanced }) => {
|
||||
const inputRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const openingAdvancedRef = useRef(false);
|
||||
const clickedOutsideRef = useRef(false);
|
||||
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
|
||||
const activeWorkspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const defaultLocation = isDefaultWorkspace
|
||||
? get(preferences, 'general.defaultLocation', '')
|
||||
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
useEffect(() => {
|
||||
const focusAndSelect = (value) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
inputRef.current.value = value;
|
||||
}
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
};
|
||||
|
||||
if (defaultLocation) {
|
||||
window.ipcRenderer?.invoke('renderer:find-unique-folder-name', 'untitled collection', defaultLocation)
|
||||
?.then((name) => focusAndSelect(name))
|
||||
?.catch(() => focusAndSelect());
|
||||
} else {
|
||||
focusAndSelect();
|
||||
}
|
||||
}, [defaultLocation]);
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isCreating || openingAdvancedRef.current) return;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const fromOutside = clickedOutsideRef.current;
|
||||
clickedOutsideRef.current = false;
|
||||
|
||||
if (isCreating || openingAdvancedRef.current) return;
|
||||
|
||||
const name = inputRef.current?.value?.trim();
|
||||
if (!name) {
|
||||
if (fromOutside) {
|
||||
onCancel();
|
||||
} else {
|
||||
toast.error('Collection name is required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateName(name)) {
|
||||
toast.error(validateNameError(name));
|
||||
if (fromOutside) {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultLocation) {
|
||||
toast.error('Please set a default location in Preferences > General');
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const folderName = sanitizeName(name);
|
||||
await dispatch(createCollection(name, folderName, defaultLocation, { format: DEFAULT_COLLECTION_FORMAT }));
|
||||
toast.success('Collection created!');
|
||||
onComplete();
|
||||
} catch (e) {
|
||||
toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e)));
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [isCreating, defaultLocation, dispatch, onCancel, onComplete]);
|
||||
|
||||
// Click outside to create
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
clickedOutsideRef.current = true;
|
||||
handleCreate();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [handleCreate]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="inline-collection-creator" ref={containerRef}>
|
||||
<div className="input-wrapper">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="inline-collection-input"
|
||||
defaultValue="untitled collection"
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<button
|
||||
className="cog-btn"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
openingAdvancedRef.current = true;
|
||||
onOpenAdvanced(inputRef.current?.value?.trim());
|
||||
}}
|
||||
title="Advanced options"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<IconSettings size={13} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Create"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancel}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineCollectionCreator;
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Collection from './Collection';
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import CreateOrOpenCollection from './CreateOrOpenCollection';
|
||||
import CollectionSearch from './CollectionSearch/index';
|
||||
import InlineCollectionCreator from './InlineCollectionCreator';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection } from 'utils/collections';
|
||||
|
||||
const Collections = ({ showSearch }) => {
|
||||
const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
|
||||
|
||||
@@ -30,24 +29,32 @@ const Collections = ({ showSearch }) => {
|
||||
if (!workspaceCollections || !workspaceCollections.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<CreateOrOpenCollection />
|
||||
{isCreatingCollection && (
|
||||
<InlineCollectionCreator
|
||||
onComplete={onDismissCreate}
|
||||
onCancel={onDismissCreate}
|
||||
onOpenAdvanced={onOpenAdvancedCreate}
|
||||
/>
|
||||
)}
|
||||
{!isCreatingCollection && <CreateOrOpenCollection onCreateClick={onCreateClick} />}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper data-testid="collections">
|
||||
{createCollectionModalOpen ? (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSearch && (
|
||||
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
|
||||
)}
|
||||
|
||||
<div className="collections-list">
|
||||
{isCreatingCollection && (
|
||||
<InlineCollectionCreator
|
||||
onComplete={onDismissCreate}
|
||||
onCancel={onDismissCreate}
|
||||
onOpenAdvanced={onOpenAdvancedCreate}
|
||||
/>
|
||||
)}
|
||||
{workspaceCollections && workspaceCollections.length
|
||||
? workspaceCollections.map((c) => {
|
||||
return (
|
||||
|
||||
@@ -18,7 +18,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import get from 'lodash/get';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => {
|
||||
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation, initialCollectionName = '' }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
|
||||
@@ -37,8 +37,8 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
collectionName: '',
|
||||
collectionFolderName: '',
|
||||
collectionName: initialCollectionName,
|
||||
collectionFolderName: initialCollectionName ? sanitizeName(initialCollectionName) : '',
|
||||
collectionLocation: defaultLocation || '',
|
||||
format: DEFAULT_COLLECTION_FORMAT
|
||||
},
|
||||
@@ -86,9 +86,13 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputRef]);
|
||||
|
||||
const AdvancedOptions = forwardRef((props, ref) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -46,11 +47,13 @@ const CollectionsSection = () => {
|
||||
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const { isCreatingCollection } = useSelector((state) => state.app);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [advancedCreateName, setAdvancedCreateName] = useState('');
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
@@ -241,13 +244,19 @@ const CollectionsSection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAdvancedCreate = (name) => {
|
||||
dispatch(setIsCreatingCollection(false));
|
||||
setAdvancedCreateName(name || '');
|
||||
setCreateCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create collection',
|
||||
onClick: () => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
dispatch(setIsCreatingCollection(true));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -359,7 +368,11 @@ const CollectionsSection = () => {
|
||||
)}
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
onClose={() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
setAdvancedCreateName('');
|
||||
}}
|
||||
initialCollectionName={advancedCreateName}
|
||||
/>
|
||||
)}
|
||||
{importCollectionModalOpen && (
|
||||
@@ -396,7 +409,13 @@ const CollectionsSection = () => {
|
||||
icon={IconBox}
|
||||
actions={sectionActions}
|
||||
>
|
||||
<Collections showSearch={showSearch} />
|
||||
<Collections
|
||||
showSearch={showSearch}
|
||||
isCreatingCollection={isCreatingCollection}
|
||||
onCreateClick={() => dispatch(setIsCreatingCollection(true))}
|
||||
onDismissCreate={() => dispatch(setIsCreatingCollection(false))}
|
||||
onOpenAdvancedCreate={handleOpenAdvancedCreate}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
|
||||
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { setIsCreatingCollection, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
|
||||
@@ -16,8 +16,8 @@ import StyledWrapper from './StyledWrapper';
|
||||
const WorkspaceOverview = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { globalEnvironments } = useSelector((state) => state.globalEnvironments);
|
||||
const { sidebarCollapsed, isCreatingCollection } = useSelector((state) => state.app);
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [importData, setImportData] = useState(null);
|
||||
@@ -29,6 +29,10 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
const workspaceEnvironmentsCount = globalEnvironments?.length || 0;
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (isCreatingCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workspace?.pathname) {
|
||||
toast.error('Workspace path not found');
|
||||
return;
|
||||
@@ -37,7 +41,10 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:ensure-collections-folder', workspace.pathname);
|
||||
setCreateCollectionModalOpen(true);
|
||||
if (sidebarCollapsed) {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
}
|
||||
dispatch(setIsCreatingCollection(true));
|
||||
} catch (error) {
|
||||
console.error('Error ensuring collections folder exists:', error);
|
||||
toast.error('Error preparing workspace for collection creation');
|
||||
@@ -87,10 +94,6 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection onClose={() => setCreateCollectionModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
@@ -142,6 +145,7 @@ const WorkspaceOverview = ({ workspace }) => {
|
||||
size="sm"
|
||||
icon={<IconPlus size={14} strokeWidth={1.5} />}
|
||||
onClick={handleCreateCollection}
|
||||
disabled={isCreatingCollection}
|
||||
>
|
||||
Create Collection
|
||||
</Button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
|
||||
import ConfirmAppClose from './ConfirmAppClose';
|
||||
import useIpcEvents from './useIpcEvents';
|
||||
import useTelemetry from './useTelemetry';
|
||||
import useParsedFileCacheIpc from './useParsedFileCacheIpc';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
@@ -14,7 +13,6 @@ export const AppContext = React.createContext();
|
||||
export const AppProvider = (props) => {
|
||||
useTelemetry({ version });
|
||||
useIpcEvents();
|
||||
useParsedFileCacheIpc();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
brunoConfigUpdateEvent,
|
||||
collectionAddDirectoryEvent,
|
||||
collectionAddFileEvent,
|
||||
collectionBatchAddItems,
|
||||
collectionChangeFileEvent,
|
||||
collectionRenamedEvent,
|
||||
collectionUnlinkDirectoryEvent,
|
||||
@@ -102,50 +101,6 @@ const useIpcEvents = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Batch handler for collection tree updates (performance optimization)
|
||||
// Uses a single Redux dispatch to process all items, avoiding multiple re-renders
|
||||
const _collectionTreeBatchUpdated = (batch) => {
|
||||
if (!batch || !Array.isArray(batch) || batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__IS_DEV__) {
|
||||
console.log('Batch update received:', batch.length, 'items');
|
||||
}
|
||||
|
||||
// Separate batch items into those that can be bulk-processed vs those that need individual handling
|
||||
const bulkItems = []; // addFile, addDir - can be processed in single reducer
|
||||
const individualItems = []; // change, unlink, etc - need individual dispatches
|
||||
|
||||
batch.forEach(({ eventType, payload }) => {
|
||||
if (eventType === 'addDir' || eventType === 'addFile') {
|
||||
bulkItems.push({ eventType, payload });
|
||||
} else {
|
||||
individualItems.push({ eventType, payload });
|
||||
}
|
||||
});
|
||||
|
||||
// Process bulk items in a single dispatch (addFile and addDir)
|
||||
if (bulkItems.length > 0) {
|
||||
dispatch(collectionBatchAddItems({ items: bulkItems }));
|
||||
}
|
||||
|
||||
// Process remaining items individually (these are typically rare during mount)
|
||||
individualItems.forEach(({ eventType, payload }) => {
|
||||
if (eventType === 'change') {
|
||||
dispatch(collectionChangeFileEvent({ file: payload }));
|
||||
} else if (eventType === 'unlink') {
|
||||
dispatch(collectionUnlinkFileEvent({ file: payload }));
|
||||
} else if (eventType === 'unlinkDir') {
|
||||
dispatch(collectionUnlinkDirectoryEvent({ directory: payload }));
|
||||
} else if (eventType === 'addEnvironmentFile') {
|
||||
dispatch(collectionAddEnvFileEvent(payload));
|
||||
} else if (eventType === 'unlinkEnvironmentFile') {
|
||||
dispatch(collectionUnlinkEnvFileEvent(payload));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _apiSpecTreeUpdated = (type, val) => {
|
||||
if (window.__IS_DEV__) {
|
||||
console.log('API Spec update:', type);
|
||||
@@ -163,8 +118,6 @@ const useIpcEvents = () => {
|
||||
|
||||
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
|
||||
|
||||
const removeCollectionTreeBatchUpdateListener = ipcRenderer.on('main:collection-tree-batch-updated', _collectionTreeBatchUpdated);
|
||||
|
||||
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
|
||||
|
||||
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
|
||||
@@ -387,7 +340,6 @@ const useIpcEvents = () => {
|
||||
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeCollectionTreeBatchUpdateListener();
|
||||
removeApiSpecTreeUpdateListener();
|
||||
removeOpenCollectionListener();
|
||||
removeOpenWorkspaceListener();
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import { parsedFileCacheStore } from 'store/parsedFileCache';
|
||||
|
||||
const useParsedFileCacheIpc = () => {
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleCacheRequest = async (operation, requestId, ...args) => {
|
||||
try {
|
||||
let result = null;
|
||||
switch (operation) {
|
||||
case 'getEntry':
|
||||
result = await parsedFileCacheStore.getEntry(...args);
|
||||
break;
|
||||
case 'setEntry':
|
||||
await parsedFileCacheStore.setEntry(...args);
|
||||
break;
|
||||
case 'invalidate':
|
||||
await parsedFileCacheStore.invalidate(...args);
|
||||
break;
|
||||
case 'invalidateCollection':
|
||||
await parsedFileCacheStore.invalidateCollection(...args);
|
||||
break;
|
||||
case 'invalidateDirectory':
|
||||
await parsedFileCacheStore.invalidateDirectory(...args);
|
||||
break;
|
||||
case 'getStats':
|
||||
result = await parsedFileCacheStore.getStats();
|
||||
break;
|
||||
case 'clear':
|
||||
await parsedFileCacheStore.clear();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown cache operation: ${operation}`);
|
||||
}
|
||||
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: true, data: result });
|
||||
} catch (error) {
|
||||
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: false, error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const removeListener = ipcRenderer.on('main:parsed-file-cache-request', handleCacheRequest);
|
||||
|
||||
// Prune old cache entries on startup
|
||||
parsedFileCacheStore.prune().catch((err) => {
|
||||
console.error('ParsedFileCacheStore: Error during startup prune:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeListener();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useParsedFileCacheIpc;
|
||||
@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
|
||||
import { createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
@@ -51,57 +51,6 @@ taskMiddleware.startListening({
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When files are added via batch processing (e.g., during collection mount or when new files are created),
|
||||
* we need to check if any of the added files match pending OPEN_REQUEST tasks.
|
||||
* This handles the case where file additions go through the batch reducer instead of individual events.
|
||||
*/
|
||||
taskMiddleware.startListening({
|
||||
actionCreator: collectionBatchAddItems,
|
||||
effect: (action, listenerApi) => {
|
||||
const state = listenerApi.getState();
|
||||
const items = action.payload?.items || [];
|
||||
|
||||
// Extract all addFile events from the batch
|
||||
const addFileItems = items.filter((item) => item.eventType === 'addFile');
|
||||
if (addFileItems.length === 0) return;
|
||||
|
||||
const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST });
|
||||
if (openRequestTasks.length === 0) return;
|
||||
|
||||
each(addFileItems, ({ payload: file }) => {
|
||||
const collectionUid = file?.meta?.collectionUid;
|
||||
if (!collectionUid) return;
|
||||
|
||||
each(openRequestTasks, (task) => {
|
||||
if (collectionUid === task.collectionUid && file?.meta?.pathname === task.itemPathname) {
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
|
||||
const item = findItemInCollectionByPathname(collection, task.itemPathname);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item) {
|
||||
listenerApi.dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: !isTransient
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
listenerApi.dispatch(
|
||||
removeTaskFromQueue({
|
||||
taskUid: task.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When an example is created or cloned, a task to open the example is added to the queue.
|
||||
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.
|
||||
|
||||
@@ -43,6 +43,11 @@ const initialState = {
|
||||
autoSave: {
|
||||
enabled: false,
|
||||
interval: 1000
|
||||
},
|
||||
cache: {
|
||||
sslSession: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
},
|
||||
generateCode: {
|
||||
@@ -61,7 +66,8 @@ const initialState = {
|
||||
envVarSearch: {
|
||||
collection: { query: '', expanded: false },
|
||||
global: { query: '', expanded: false }
|
||||
}
|
||||
},
|
||||
isCreatingCollection: false
|
||||
};
|
||||
|
||||
export const appSlice = createSlice({
|
||||
@@ -157,6 +163,9 @@ export const appSlice = createSlice({
|
||||
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
|
||||
if (!state.envVarSearch[context]) return;
|
||||
state.envVarSearch[context].expanded = expanded;
|
||||
},
|
||||
setIsCreatingCollection: (state, action) => {
|
||||
state.isCreatingCollection = action.payload;
|
||||
}
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
@@ -200,7 +209,8 @@ export const {
|
||||
setGitVersion,
|
||||
setClipboard,
|
||||
setEnvVarSearchQuery,
|
||||
setEnvVarSearchExpanded
|
||||
setEnvVarSearchExpanded,
|
||||
setIsCreatingCollection
|
||||
} = appSlice.actions;
|
||||
|
||||
export const savePreferences = (preferences) => (dispatch, getState) => {
|
||||
@@ -296,4 +306,11 @@ export const refreshSystemProxy = () => (dispatch, getState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const clearHttpHttpsAgentCache = () => () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:clear-http-https-agent-cache').then(resolve).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default appSlice.reducer;
|
||||
|
||||
@@ -162,7 +162,6 @@ export const collectionsSlice = createSlice({
|
||||
collection.settingsSelectedTab = 'overview';
|
||||
collection.folderLevelSettingsSelectedTab = {};
|
||||
collection.allTags = []; // Initialize collection-level tags
|
||||
collection.isLoading = false;
|
||||
|
||||
// Collection mount status is used to track the mount status of the collection
|
||||
// values can be 'unmounted', 'mounting', 'mounted'
|
||||
@@ -2770,224 +2769,6 @@ export const collectionsSlice = createSlice({
|
||||
addDepth(collection.items);
|
||||
}
|
||||
},
|
||||
// Batch reducer for adding multiple files/directories in a single state update
|
||||
// This is a performance optimization to avoid multiple re-renders during collection mount
|
||||
collectionBatchAddItems: (state, action) => {
|
||||
const { items } = action.payload;
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group items by collection to minimize lookups
|
||||
const itemsByCollection = new Map();
|
||||
for (const item of items) {
|
||||
const collectionUid = item.payload?.meta?.collectionUid || item.payload?.collectionUid;
|
||||
if (!collectionUid) continue;
|
||||
|
||||
if (!itemsByCollection.has(collectionUid)) {
|
||||
itemsByCollection.set(collectionUid, []);
|
||||
}
|
||||
itemsByCollection.get(collectionUid).push(item);
|
||||
}
|
||||
|
||||
// Process each collection's items
|
||||
for (const [collectionUid, collectionItems] of itemsByCollection) {
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
if (!collection) continue;
|
||||
const tempDirectory = state.tempDirectories?.[collectionUid];
|
||||
const folderIndex = new Map();
|
||||
const folderStack = [...collection.items];
|
||||
while (folderStack.length) {
|
||||
const item = folderStack.pop();
|
||||
if (item?.type === 'folder' && item.pathname) {
|
||||
folderIndex.set(item.pathname, item);
|
||||
if (item.items && item.items.length) {
|
||||
folderStack.push(...item.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { eventType, payload } of collectionItems) {
|
||||
if (eventType === 'addDir') {
|
||||
const dir = payload;
|
||||
const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
let lastFolder = null;
|
||||
|
||||
for (const directoryName of subDirectories) {
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
let childItem = folderIndex.get(currentPath);
|
||||
if (!childItem) {
|
||||
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
|
||||
}
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: dir?.meta?.uid || uuid(),
|
||||
pathname: currentPath,
|
||||
name: dir?.meta?.name || directoryName,
|
||||
seq: dir?.meta?.seq,
|
||||
filename: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
items: [],
|
||||
isTransient: isTransientDir
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
folderIndex.set(currentPath, childItem);
|
||||
} else if (isTransientDir && !childItem.isTransient) {
|
||||
childItem.isTransient = true;
|
||||
}
|
||||
|
||||
currentSubItems = childItem.items;
|
||||
lastFolder = childItem;
|
||||
}
|
||||
|
||||
if (lastFolder) {
|
||||
if (dir?.meta?.name) {
|
||||
lastFolder.name = dir.meta.name;
|
||||
}
|
||||
if (dir?.meta?.seq) {
|
||||
lastFolder.seq = dir.meta.seq;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === 'addFile') {
|
||||
const file = payload;
|
||||
const isCollectionRoot = file.meta.collectionRoot ? true : false;
|
||||
const isFolderRoot = file.meta.folderRoot ? true : false;
|
||||
const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory);
|
||||
|
||||
if (isCollectionRoot) {
|
||||
collection.root = mergeRootWithPreservedUids(collection.root, file.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFolderRoot) {
|
||||
const folderPath = path.dirname(file.meta.pathname);
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
let folderItem = folderIndex.get(folderPath);
|
||||
|
||||
if (!folderItem) {
|
||||
for (const directoryName of subDirectories) {
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
let childItem = folderIndex.get(currentPath);
|
||||
if (!childItem) {
|
||||
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
|
||||
}
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: uuid(),
|
||||
pathname: currentPath,
|
||||
name: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
items: [],
|
||||
isTransient: isTransientFile
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
folderIndex.set(currentPath, childItem);
|
||||
} else if (isTransientFile && !childItem.isTransient) {
|
||||
childItem.isTransient = true;
|
||||
}
|
||||
currentSubItems = childItem.items;
|
||||
if (currentPath === folderPath) {
|
||||
folderItem = childItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (folderItem) {
|
||||
if (file?.data?.meta?.name) {
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
}
|
||||
folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
|
||||
if (file?.data?.meta?.seq) {
|
||||
folderItem.seq = file.data?.meta?.seq;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirname = path.dirname(file.meta.pathname);
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
for (const directoryName of subDirectories) {
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
let childItem = folderIndex.get(currentPath);
|
||||
if (!childItem) {
|
||||
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
|
||||
}
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: uuid(),
|
||||
pathname: currentPath,
|
||||
name: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
items: [],
|
||||
isTransient: isTransientFile
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
folderIndex.set(currentPath, childItem);
|
||||
} else if (isTransientFile && !childItem.isTransient) {
|
||||
childItem.isTransient = true;
|
||||
}
|
||||
currentSubItems = childItem.items;
|
||||
}
|
||||
|
||||
if (file.meta.name !== 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
|
||||
const currentItem = find(currentSubItems, (i) => i.uid === file.data.uid);
|
||||
if (currentItem) {
|
||||
currentItem.name = file.data.name;
|
||||
currentItem.type = file.data.type;
|
||||
currentItem.seq = file.data.seq;
|
||||
currentItem.tags = file.data.tags;
|
||||
currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
|
||||
currentItem.filename = file.meta.name;
|
||||
currentItem.pathname = file.meta.pathname;
|
||||
currentItem.settings = file.data.settings;
|
||||
currentItem.examples = file.data.examples;
|
||||
currentItem.draft = null;
|
||||
currentItem.partial = file.partial;
|
||||
currentItem.loading = file.loading;
|
||||
currentItem.size = file.size;
|
||||
currentItem.error = file.error;
|
||||
currentItem.isTransient = isTransientFile;
|
||||
} else {
|
||||
currentSubItems.push({
|
||||
uid: file.data.uid,
|
||||
name: file.data.name,
|
||||
type: file.data.type,
|
||||
seq: file.data.seq,
|
||||
tags: file.data.tags,
|
||||
request: file.data.request,
|
||||
settings: file.data.settings,
|
||||
examples: file.data.examples,
|
||||
filename: file.meta.name,
|
||||
pathname: file.meta.pathname,
|
||||
draft: null,
|
||||
partial: file.partial,
|
||||
loading: file.loading,
|
||||
size: file.size,
|
||||
error: file.error,
|
||||
isTransient: isTransientFile
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call addDepth once per collection after all items are added
|
||||
addDepth(collection.items);
|
||||
}
|
||||
},
|
||||
collectionChangeFileEvent: (state, action) => {
|
||||
const { file } = action.payload;
|
||||
const isCollectionRoot = file.meta.collectionRoot ? true : false;
|
||||
@@ -3002,33 +2783,7 @@ export const collectionsSlice = createSlice({
|
||||
|
||||
if (isFolderRoot) {
|
||||
const folderPath = path.dirname(file.meta.pathname);
|
||||
let folderItem = findItemInCollectionByPathname(collection, folderPath);
|
||||
|
||||
if (!folderItem && collection) {
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
for (const directoryName of subDirectories) {
|
||||
let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
|
||||
currentPath = path.join(currentPath, directoryName);
|
||||
if (!childItem) {
|
||||
childItem = {
|
||||
uid: uuid(),
|
||||
pathname: currentPath,
|
||||
name: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
items: []
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
}
|
||||
currentSubItems = childItem.items;
|
||||
if (currentPath === folderPath) {
|
||||
folderItem = childItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const folderItem = findItemInCollectionByPathname(collection, folderPath);
|
||||
if (folderItem) {
|
||||
if (file?.data?.meta?.name) {
|
||||
folderItem.name = file?.data?.meta?.name;
|
||||
@@ -3907,7 +3662,6 @@ export const {
|
||||
updateCollectionProtobuf,
|
||||
collectionAddFileEvent,
|
||||
collectionAddDirectoryEvent,
|
||||
collectionBatchAddItems,
|
||||
collectionChangeFileEvent,
|
||||
collectionUnlinkFileEvent,
|
||||
collectionUnlinkDirectoryEvent,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { removeCollection, addTransientDirectory, updateCollectionMountStatus }
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { addTab, focusTab } from '../tabs';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
@@ -50,6 +51,21 @@ const transformCollection = async (collection, type) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a workspace with a unique name under the given location
|
||||
*/
|
||||
export const createWorkspaceWithUniqueName = (location) => {
|
||||
return async (dispatch) => {
|
||||
const name = await ipcRenderer?.invoke('renderer:find-unique-folder-name', 'untitled workspace', location) || 'untitled workspace';
|
||||
const folderName = sanitizeName(name);
|
||||
const result = await dispatch(createWorkspaceAction(name, folderName, location));
|
||||
if (result?.workspaceUid) {
|
||||
dispatch(updateWorkspace({ uid: result.workspaceUid, isNewlyCreated: true }));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import { openDB } from 'idb';
|
||||
import path from 'utils/common/path';
|
||||
|
||||
const DB_NAME = 'bruno-parsed-file-cache';
|
||||
const STORE_NAME = 'parsedFiles';
|
||||
const DB_VERSION = 1;
|
||||
const CACHE_VERSION = '1.0.0';
|
||||
const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
let dbPromise = null;
|
||||
|
||||
const getDB = () => {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
||||
store.createIndex('collectionPath', 'collectionPath');
|
||||
store.createIndex('parsedAt', 'parsedAt');
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
dbPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
const generateKey = (collectionPath, filePath) => {
|
||||
return `${collectionPath}↝${filePath}`;
|
||||
};
|
||||
|
||||
export const parsedFileCacheStore = {
|
||||
async getEntry(collectionPath, filePath) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const key = generateKey(collectionPath, filePath);
|
||||
const entry = await db.get(STORE_NAME, key);
|
||||
|
||||
if (entry && typeof entry.mtimeMs === 'number' && entry.parsedData) {
|
||||
return {
|
||||
mtimeMs: entry.mtimeMs,
|
||||
parsedData: entry.parsedData
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error reading cache entry:', error);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async setEntry(collectionPath, filePath, entry, retryAfterEviction = true) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const key = generateKey(collectionPath, filePath);
|
||||
const cacheEntry = {
|
||||
key,
|
||||
collectionPath,
|
||||
filePath,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
parsedData: entry.parsedData,
|
||||
parsedAt: Date.now()
|
||||
};
|
||||
await db.put(STORE_NAME, cacheEntry);
|
||||
} catch (error) {
|
||||
// Handle QuotaExceededError by evicting old entries and retrying
|
||||
const isQuotaError
|
||||
= error.name === 'QuotaExceededError'
|
||||
|| error.code === 22 // Legacy Safari
|
||||
|| (error.code === 1014 && error.name === 'NS_ERROR_DOM_QUOTA_REACHED'); // Firefox
|
||||
|
||||
if (isQuotaError && retryAfterEviction) {
|
||||
console.warn('ParsedFileCacheStore: Quota exceeded, evicting old entries...');
|
||||
const evicted = await this.evictLRU();
|
||||
if (evicted > 0) {
|
||||
// Retry the write after eviction
|
||||
return this.setEntry(collectionPath, filePath, entry, false);
|
||||
}
|
||||
console.warn('ParsedFileCacheStore: No entries to evict, cache write skipped');
|
||||
} else {
|
||||
console.error('ParsedFileCacheStore: Error writing cache entry:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async invalidate(collectionPath, filePath) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const key = generateKey(collectionPath, filePath);
|
||||
await db.delete(STORE_NAME, key);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async invalidateCollection(collectionPath) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const index = tx.store.index('collectionPath');
|
||||
|
||||
let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
|
||||
while (cursor) {
|
||||
await cursor.delete();
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
await tx.done;
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async invalidateDirectory(collectionPath, dirPath) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const index = tx.store.index('collectionPath');
|
||||
const normalizedDirPath = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
|
||||
|
||||
let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
|
||||
while (cursor) {
|
||||
if (cursor.value.filePath.startsWith(normalizedDirPath)) {
|
||||
await cursor.delete();
|
||||
}
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
await tx.done;
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async moveEntry(collectionPath, oldFilePath, newFilePath) {
|
||||
try {
|
||||
const entry = await this.getEntry(collectionPath, oldFilePath);
|
||||
if (entry) {
|
||||
await this.invalidate(collectionPath, oldFilePath);
|
||||
await this.setEntry(collectionPath, newFilePath, {
|
||||
mtimeMs: entry.mtimeMs,
|
||||
parsedData: entry.parsedData
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error moving cache entry:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async prune(maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const index = tx.store.index('parsedAt');
|
||||
|
||||
let cursor = await index.openCursor(IDBKeyRange.upperBound(cutoff));
|
||||
while (cursor) {
|
||||
await cursor.delete();
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
await tx.done;
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error pruning cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Evict least recently used entries when quota is exceeded.
|
||||
* Removes approximately 20% of the oldest entries to free up space.
|
||||
* @returns {Promise<number>} Number of entries evicted
|
||||
*/
|
||||
async evictLRU(percentageToEvict = 0.2) {
|
||||
try {
|
||||
const db = await getDB();
|
||||
const totalCount = await db.count(STORE_NAME);
|
||||
|
||||
if (totalCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const countToEvict = Math.max(1, Math.floor(totalCount * percentageToEvict));
|
||||
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const index = tx.store.index('parsedAt');
|
||||
|
||||
let cursor = await index.openCursor();
|
||||
let evicted = 0;
|
||||
|
||||
while (cursor && evicted < countToEvict) {
|
||||
await cursor.delete();
|
||||
evicted++;
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
|
||||
await tx.done;
|
||||
console.log(`ParsedFileCacheStore: Evicted ${evicted} LRU entries to free up space`);
|
||||
return evicted;
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error during LRU eviction:', error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
const db = await getDB();
|
||||
await db.clear(STORE_NAME);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error clearing cache:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async getStats() {
|
||||
try {
|
||||
const db = await getDB();
|
||||
|
||||
// Use count() for O(1) total files count
|
||||
const totalFiles = await db.count(STORE_NAME);
|
||||
|
||||
// Count unique collections using index with unique cursor
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const index = tx.store.index('collectionPath');
|
||||
let totalCollections = 0;
|
||||
|
||||
// Use openKeyCursor with 'nextunique' to count unique collection paths
|
||||
let cursor = await index.openKeyCursor(null, 'nextunique');
|
||||
while (cursor) {
|
||||
totalCollections++;
|
||||
cursor = await cursor.continue();
|
||||
}
|
||||
|
||||
return {
|
||||
version: CACHE_VERSION,
|
||||
totalCollections,
|
||||
totalFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error getting stats:', error);
|
||||
return {
|
||||
version: CACHE_VERSION,
|
||||
totalCollections: 0,
|
||||
totalFiles: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default parsedFileCacheStore;
|
||||
@@ -3,6 +3,9 @@ import { mockDataFunctions } from '@usebruno/common';
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
// Static API hints - Bruno JavaScript API (subgrouped by category)
|
||||
// TODO: Restore the commented-out APIs once the UI update fixes are live.
|
||||
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
|
||||
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
|
||||
const STATIC_API_HINTS = {
|
||||
req: [
|
||||
'req',
|
||||
@@ -67,11 +70,11 @@ const STATIC_API_HINTS = {
|
||||
'bru.getEnvVar(key)',
|
||||
'bru.getFolderVar(key)',
|
||||
'bru.getCollectionVar(key)',
|
||||
'bru.setCollectionVar(key, value)',
|
||||
// 'bru.setCollectionVar(key, value)',
|
||||
'bru.hasCollectionVar(key)',
|
||||
'bru.deleteCollectionVar(key)',
|
||||
'bru.deleteAllCollectionVars()',
|
||||
'bru.getAllCollectionVars()',
|
||||
// 'bru.deleteCollectionVar(key)',
|
||||
// 'bru.deleteAllCollectionVars()',
|
||||
// 'bru.getAllCollectionVars()',
|
||||
'bru.setEnvVar(key, value)',
|
||||
'bru.setEnvVar(key, value, options)',
|
||||
'bru.deleteEnvVar(key)',
|
||||
@@ -96,9 +99,9 @@ const STATIC_API_HINTS = {
|
||||
'bru.getOauth2CredentialVar(key)',
|
||||
'bru.getGlobalEnvVar(key)',
|
||||
'bru.setGlobalEnvVar(key, value)',
|
||||
'bru.deleteGlobalEnvVar(key)',
|
||||
// 'bru.deleteGlobalEnvVar(key)',
|
||||
'bru.getAllGlobalEnvVars()',
|
||||
'bru.deleteAllGlobalEnvVars()',
|
||||
// 'bru.deleteAllGlobalEnvVars()',
|
||||
'bru.runner',
|
||||
'bru.runner.setNextRequest(requestName)',
|
||||
'bru.runner.skipRequest()',
|
||||
|
||||
@@ -225,6 +225,11 @@ const builder = async (yargs) => {
|
||||
description: 'Disable all proxy settings (both collection-defined and system proxies)',
|
||||
default: false
|
||||
})
|
||||
.option('cache-ssl-session', {
|
||||
type: 'boolean',
|
||||
description: 'Enable SSL session caching — reuses TLS sessions across requests for faster handshakes',
|
||||
default: false
|
||||
})
|
||||
.option('delay', {
|
||||
type: 'number',
|
||||
description: 'Delay between each requests (in miliseconds)'
|
||||
@@ -330,6 +335,7 @@ const handler = async function (argv) {
|
||||
reporterSkipBody,
|
||||
clientCertConfig,
|
||||
noproxy,
|
||||
cacheSslSession,
|
||||
delay,
|
||||
tags: includeTags,
|
||||
excludeTags,
|
||||
@@ -531,6 +537,9 @@ const handler = async function (argv) {
|
||||
if (noproxy) {
|
||||
options['noproxy'] = true;
|
||||
}
|
||||
if (cacheSslSession) {
|
||||
options['cacheSslSession'] = true;
|
||||
}
|
||||
if (verbose) {
|
||||
options['verbose'] = true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ const { interpolateString, interpolateObject } = require('./interpolate-string')
|
||||
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
|
||||
const { stripExtension } = require('../utils/filesystem');
|
||||
const { getOptions } = require('../utils/bru');
|
||||
const https = require('https');
|
||||
const https = require('node:https');
|
||||
const http = require('node:http');
|
||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { makeAxiosInstance } = require('../utils/axios-instance');
|
||||
@@ -22,7 +23,7 @@ const { createFormData } = require('../utils/form-data');
|
||||
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 { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
|
||||
const tokenStore = require('../store/tokenStore');
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
|
||||
@@ -203,7 +204,8 @@ const runSingleRequest = async function (
|
||||
shouldVerifyTls: !get(options, 'insecure', false),
|
||||
shouldUseCustomCaCertificate: !!options['cacert'],
|
||||
customCaCertificateFilePath: options['cacert'],
|
||||
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
|
||||
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
|
||||
cacheSslSession: get(options, 'cacheSslSession', false)
|
||||
},
|
||||
clientCertificates: rawClientCertificates ? interpolateObject(rawClientCertificates, sendRequestInterpolationOptions) : undefined,
|
||||
collectionLevelProxy: transformProxyConfig(interpolateObject(rawProxyConfig, sendRequestInterpolationOptions)),
|
||||
@@ -347,6 +349,7 @@ const runSingleRequest = async function (
|
||||
const insecure = get(options, 'insecure', false);
|
||||
const noproxy = get(options, 'noproxy', false);
|
||||
const cachedSystemProxy = get(options, 'cachedSystemProxy', null);
|
||||
const disableCache = !get(options, 'cacheSslSession', false);
|
||||
const httpsAgentRequestFields = {};
|
||||
|
||||
if (insecure) {
|
||||
@@ -426,6 +429,18 @@ const runSingleRequest = async function (
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
// Prepare TLS options for agent caching
|
||||
const tlsOptions = {
|
||||
...httpsAgentRequestFields
|
||||
};
|
||||
|
||||
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
|
||||
const httpAgentOptions = { keepAlive: true };
|
||||
|
||||
const parsedRequestUrl = new URL(request.url);
|
||||
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
|
||||
const hostname = parsedRequestUrl.hostname || null;
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (shouldProxy) {
|
||||
@@ -444,35 +459,37 @@ const runSingleRequest = async function (
|
||||
} else {
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
|
||||
// Only set the agent needed for the request protocol
|
||||
if (socksEnabled) {
|
||||
request.httpsAgent = new SocksProxyAgent(
|
||||
proxyUri,
|
||||
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
|
||||
);
|
||||
request.httpAgent = new SocksProxyAgent(proxyUri);
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
request.httpsAgent = new PatchedHttpsProxyAgent(
|
||||
proxyUri,
|
||||
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
|
||||
);
|
||||
request.httpAgent = new HttpProxyAgent(proxyUri);
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.httpsAgent = new https.Agent({
|
||||
...httpsAgentRequestFields
|
||||
});
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
try {
|
||||
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
|
||||
const parsedUrl = new URL(request.url);
|
||||
const isHttpsRequest = parsedUrl.protocol === 'https:';
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
new URL(http_proxy);
|
||||
request.httpAgent = new HttpProxyAgent(http_proxy);
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
@@ -480,30 +497,21 @@ const runSingleRequest = async function (
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
request.httpsAgent = new PatchedHttpsProxyAgent(https_proxy,
|
||||
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined);
|
||||
} else {
|
||||
request.httpsAgent = new https.Agent({
|
||||
...httpsAgentRequestFields
|
||||
});
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
} else {
|
||||
request.httpsAgent = new https.Agent({
|
||||
...httpsAgentRequestFields
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
...httpsAgentRequestFields
|
||||
});
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!request.httpAgent && !request.httpsAgent) {
|
||||
if (isHttpsRequest) {
|
||||
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
|
||||
} else {
|
||||
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
|
||||
}
|
||||
} else if (Object.keys(httpsAgentRequestFields).length > 0) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
...httpsAgentRequestFields
|
||||
});
|
||||
}
|
||||
|
||||
// set cookies if enabled
|
||||
@@ -610,12 +618,13 @@ const runSingleRequest = async function (
|
||||
|
||||
let token;
|
||||
if (oauth2RequestUrl) {
|
||||
const tlsOptions = {
|
||||
const oauth2ConfigOptions = {
|
||||
noproxy: options.noproxy,
|
||||
shouldVerifyTls: !insecure,
|
||||
shouldUseCustomCaCertificate: !!options['cacert'],
|
||||
customCaCertificateFilePath: options['cacert'],
|
||||
shouldKeepDefaultCaCertificates: !options['ignoreTruststore']
|
||||
shouldKeepDefaultCaCertificates: !options['ignoreTruststore'],
|
||||
cacheSslSession: !disableCache
|
||||
};
|
||||
|
||||
const clientCertificates = get(brunoConfig, 'clientCertificates');
|
||||
@@ -627,7 +636,7 @@ const runSingleRequest = async function (
|
||||
const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({
|
||||
requestUrl: oauth2RequestUrl,
|
||||
collectionPath,
|
||||
options: tlsOptions,
|
||||
options: oauth2ConfigOptions,
|
||||
clientCertificates: interpolatedClientCertificates,
|
||||
collectionLevelProxy: interpolatedProxyConfig,
|
||||
systemProxyConfig
|
||||
|
||||
@@ -63,9 +63,17 @@ const shouldUseProxy = (url, proxyBypass) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent to get around a bug that ignores
|
||||
* options like ca and rejectUnauthorized when upgrading the socket to TLS:
|
||||
* https://github.com/TooTallNate/proxy-agents/issues/194
|
||||
* Options that should be forwarded from the constructor to the target TLS upgrade.
|
||||
*/
|
||||
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent that correctly handles TLS options for
|
||||
* both the proxy connection and the target server connection.
|
||||
*
|
||||
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
|
||||
* ignores constructor options when upgrading the tunneled socket to TLS for the
|
||||
* target server. This patch forwards the relevant TLS options to the target upgrade.
|
||||
*/
|
||||
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
constructor(proxy, opts) {
|
||||
@@ -74,8 +82,17 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
}
|
||||
|
||||
async connect(req, opts) {
|
||||
const combinedOpts = { ...this.constructorOpts, ...opts };
|
||||
return super.connect(req, combinedOpts);
|
||||
const targetOpts = { ...opts };
|
||||
|
||||
if (this.constructorOpts) {
|
||||
for (const key of TARGET_TLS_OPTIONS) {
|
||||
if (key in this.constructorOpts) {
|
||||
targetOpts[key] = this.constructorOpts[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.connect(req, targetOpts);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import translateCode from '../utils/postman-to-bruno-translator';
|
||||
|
||||
// TODO: Restore the commented-out translations once the UI update fixes are live.
|
||||
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
|
||||
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
|
||||
const replacements = {
|
||||
'pm\\.environment\\.get\\(': 'bru.getEnvVar(',
|
||||
'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
|
||||
@@ -7,11 +10,11 @@ const replacements = {
|
||||
'pm\\.variables\\.set\\(': 'bru.setVar(',
|
||||
'pm\\.variables\\.replaceIn\\(': 'bru.interpolate(',
|
||||
'pm\\.collectionVariables\\.get\\(': 'bru.getCollectionVar(',
|
||||
'pm\\.collectionVariables\\.set\\(': 'bru.setCollectionVar(',
|
||||
// 'pm\\.collectionVariables\\.set\\(': 'bru.setCollectionVar(',
|
||||
'pm\\.collectionVariables\\.has\\(': 'bru.hasCollectionVar(',
|
||||
'pm\\.collectionVariables\\.unset\\(': 'bru.deleteCollectionVar(',
|
||||
'pm\\.collectionVariables\\.clear\\(': 'bru.deleteAllCollectionVars(',
|
||||
'pm\\.collectionVariables\\.toObject\\(': 'bru.getAllCollectionVars(',
|
||||
// 'pm\\.collectionVariables\\.unset\\(': 'bru.deleteCollectionVar(',
|
||||
// 'pm\\.collectionVariables\\.clear\\(': 'bru.deleteAllCollectionVars(',
|
||||
// 'pm\\.collectionVariables\\.toObject\\(': 'bru.getAllCollectionVars(',
|
||||
'pm\\.setNextRequest\\(': 'bru.setNextRequest(',
|
||||
'pm\\.test\\(': 'test(',
|
||||
'pm.response.to.have\\.status\\(': 'expect(res.getStatus()).to.equal(',
|
||||
@@ -25,9 +28,9 @@ const replacements = {
|
||||
'pm\\.response\\.responseTime': 'res.getResponseTime()',
|
||||
'pm\\.globals\\.set\\(': 'bru.setGlobalEnvVar(',
|
||||
'pm\\.globals\\.get\\(': 'bru.getGlobalEnvVar(',
|
||||
'pm\\.globals\\.unset\\(': 'bru.deleteGlobalEnvVar(',
|
||||
// 'pm\\.globals\\.unset\\(': 'bru.deleteGlobalEnvVar(',
|
||||
'pm\\.globals\\.toObject\\(': 'bru.getAllGlobalEnvVars(',
|
||||
'pm\\.globals\\.clear\\(': 'bru.deleteAllGlobalEnvVars(',
|
||||
// 'pm\\.globals\\.clear\\(': 'bru.deleteAllGlobalEnvVars(',
|
||||
'pm\\.environment\\.toObject\\(': 'bru.getAllEnvVars(',
|
||||
'pm\\.environment\\.clear\\(': 'bru.deleteAllEnvVars(',
|
||||
'pm\\.variables\\.toObject\\(': 'bru.getAllVars(',
|
||||
|
||||
@@ -13,13 +13,16 @@ const j = require('jscodeshift');
|
||||
* Simple 1:1 translations from Bruno helpers to Postman helpers.
|
||||
* These are direct member expression replacements.
|
||||
*/
|
||||
// TODO: Restore the commented-out translations once the UI update fixes are live.
|
||||
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
|
||||
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
|
||||
const simpleTranslations = {
|
||||
// Global variables
|
||||
'bru.getGlobalEnvVar': 'pm.globals.get',
|
||||
'bru.setGlobalEnvVar': 'pm.globals.set',
|
||||
'bru.deleteGlobalEnvVar': 'pm.globals.unset',
|
||||
// 'bru.deleteGlobalEnvVar': 'pm.globals.unset',
|
||||
'bru.getAllGlobalEnvVars': 'pm.globals.toObject',
|
||||
'bru.deleteAllGlobalEnvVars': 'pm.globals.clear',
|
||||
// 'bru.deleteAllGlobalEnvVars': 'pm.globals.clear',
|
||||
|
||||
// Environment variables
|
||||
'bru.getEnvVar': 'pm.environment.get',
|
||||
@@ -40,11 +43,11 @@ const simpleTranslations = {
|
||||
|
||||
// Collection variables
|
||||
'bru.getCollectionVar': 'pm.collectionVariables.get',
|
||||
'bru.setCollectionVar': 'pm.collectionVariables.set',
|
||||
// 'bru.setCollectionVar': 'pm.collectionVariables.set',
|
||||
'bru.hasCollectionVar': 'pm.collectionVariables.has',
|
||||
'bru.deleteCollectionVar': 'pm.collectionVariables.unset',
|
||||
'bru.getAllCollectionVars': 'pm.collectionVariables.toObject',
|
||||
'bru.deleteAllCollectionVars': 'pm.collectionVariables.clear',
|
||||
// 'bru.deleteCollectionVar': 'pm.collectionVariables.unset',
|
||||
// 'bru.getAllCollectionVars': 'pm.collectionVariables.toObject',
|
||||
// 'bru.deleteAllCollectionVars': 'pm.collectionVariables.clear',
|
||||
|
||||
// Folder variables
|
||||
'bru.getFolderVar': 'pm.variables.get',
|
||||
|
||||
@@ -4,14 +4,17 @@ const j = require('jscodeshift');
|
||||
const cloneDeep = require('lodash/cloneDeep');
|
||||
|
||||
// Simple 1:1 translations for straightforward replacements
|
||||
// TODO: Restore the commented-out translations once the UI update fixes are live.
|
||||
// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
|
||||
// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
|
||||
const simpleTranslations = {
|
||||
// Global Variables
|
||||
'pm.globals.get': 'bru.getGlobalEnvVar',
|
||||
'pm.globals.set': 'bru.setGlobalEnvVar',
|
||||
'pm.globals.replaceIn': 'bru.interpolate',
|
||||
'pm.globals.unset': 'bru.deleteGlobalEnvVar',
|
||||
// 'pm.globals.unset': 'bru.deleteGlobalEnvVar',
|
||||
'pm.globals.toObject': 'bru.getAllGlobalEnvVars',
|
||||
'pm.globals.clear': 'bru.deleteAllGlobalEnvVars',
|
||||
// 'pm.globals.clear': 'bru.deleteAllGlobalEnvVars',
|
||||
|
||||
// Environment variables
|
||||
'pm.environment.get': 'bru.getEnvVar',
|
||||
@@ -30,12 +33,12 @@ const simpleTranslations = {
|
||||
'pm.variables.replaceIn': 'bru.interpolate',
|
||||
// Collection variables
|
||||
'pm.collectionVariables.get': 'bru.getCollectionVar',
|
||||
'pm.collectionVariables.set': 'bru.setCollectionVar',
|
||||
// 'pm.collectionVariables.set': 'bru.setCollectionVar',
|
||||
'pm.collectionVariables.has': 'bru.hasCollectionVar',
|
||||
'pm.collectionVariables.unset': 'bru.deleteCollectionVar',
|
||||
// 'pm.collectionVariables.unset': 'bru.deleteCollectionVar',
|
||||
'pm.collectionVariables.replaceIn': 'bru.interpolate',
|
||||
'pm.collectionVariables.clear': 'bru.deleteAllCollectionVars',
|
||||
'pm.collectionVariables.toObject': 'bru.getAllCollectionVars',
|
||||
// 'pm.collectionVariables.clear': 'bru.deleteAllCollectionVars',
|
||||
// 'pm.collectionVariables.toObject': 'bru.getAllCollectionVars',
|
||||
|
||||
// Request flow control
|
||||
'pm.setNextRequest': 'bru.setNextRequest',
|
||||
|
||||
@@ -58,7 +58,8 @@ describe('Bruno to Postman Variables Translation', () => {
|
||||
expect(translatedCode).toBe('pm.collectionVariables.get("baseUrl");');
|
||||
});
|
||||
|
||||
it('should translate bru.setCollectionVar', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate bru.setCollectionVar', () => {
|
||||
const code = 'bru.setCollectionVar("baseUrl", "https://api.example.com");';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('pm.collectionVariables.set("baseUrl", "https://api.example.com");');
|
||||
@@ -70,19 +71,22 @@ describe('Bruno to Postman Variables Translation', () => {
|
||||
expect(translatedCode).toBe('pm.collectionVariables.has("baseUrl");');
|
||||
});
|
||||
|
||||
it('should translate bru.deleteCollectionVar', () => {
|
||||
// TODO: Restore once UI update fixes are live for deleteCollectionVar
|
||||
it.skip('should translate bru.deleteCollectionVar', () => {
|
||||
const code = 'bru.deleteCollectionVar("baseUrl");';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('pm.collectionVariables.unset("baseUrl");');
|
||||
});
|
||||
|
||||
it('should translate bru.getAllCollectionVars', () => {
|
||||
// TODO: Restore once UI update fixes are live for getAllCollectionVars
|
||||
it.skip('should translate bru.getAllCollectionVars', () => {
|
||||
const code = 'const vars = bru.getAllCollectionVars();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('const vars = pm.collectionVariables.toObject();');
|
||||
});
|
||||
|
||||
it('should translate bru.deleteAllCollectionVars', () => {
|
||||
// TODO: Restore once UI update fixes are live for deleteAllCollectionVars
|
||||
it.skip('should translate bru.deleteAllCollectionVars', () => {
|
||||
const code = 'bru.deleteAllCollectionVars();';
|
||||
const translatedCode = translateBruToPostman(code);
|
||||
expect(translatedCode).toBe('pm.collectionVariables.clear();');
|
||||
|
||||
@@ -7,11 +7,15 @@ describe('postmanTranslations - comment handling', () => {
|
||||
const data = pm.environment.get('key');
|
||||
pm.collectionVariables.set('key', data);
|
||||
`;
|
||||
const expectedOutput = `
|
||||
console.log('This script does not contain pm commands.');
|
||||
const data = bru.getEnvVar('key');
|
||||
bru.setCollectionVar('key', data);
|
||||
`;
|
||||
const result = postmanTranslation(inputScript);
|
||||
expect(result).toContain('console.log(\'This script does not contain pm commands.\');');
|
||||
expect(result).toContain('const data = bru.getEnvVar(\'key\');');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
test.skip('should translate pm.collectionVariables.set to bru.setCollectionVar', () => {
|
||||
const inputScript = 'pm.collectionVariables.set(\'key\', data);';
|
||||
const expectedOutput = 'bru.setCollectionVar(\'key\', data);';
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
import postmanTranslation from '../../../src/postman/postman-translations';
|
||||
|
||||
describe('postmanTranslations - variables commands', () => {
|
||||
test('should translate variable commands correctly', () => {
|
||||
test('should translate environment variable commands', () => {
|
||||
const inputScript = `
|
||||
pm.environment.get('key');
|
||||
pm.environment.set('key', 'value');
|
||||
`;
|
||||
const result = postmanTranslation(inputScript);
|
||||
expect(result).toContain('bru.getEnvVar(\'key\')');
|
||||
expect(result).toContain('bru.setEnvVar(\'key\', \'value\')');
|
||||
});
|
||||
|
||||
test('should translate runtime variable commands', () => {
|
||||
const inputScript = `
|
||||
pm.variables.get('key');
|
||||
pm.variables.set('key', 'value');
|
||||
pm.collectionVariables.get('key');
|
||||
pm.collectionVariables.set('key', 'value');
|
||||
pm.expect(pm.environment.has('key')).to.be.true;
|
||||
`;
|
||||
const expectedOutput = `
|
||||
bru.getEnvVar('key');
|
||||
bru.setEnvVar('key', 'value');
|
||||
bru.getVar('key');
|
||||
bru.setVar('key', 'value');
|
||||
bru.getCollectionVar('key');
|
||||
bru.setCollectionVar('key', 'value');
|
||||
expect(bru.getEnvVar('key') !== undefined && bru.getEnvVar('key') !== null).to.be.true;
|
||||
`;
|
||||
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
|
||||
const result = postmanTranslation(inputScript);
|
||||
expect(result).toContain('bru.getVar(\'key\')');
|
||||
expect(result).toContain('bru.setVar(\'key\', \'value\')');
|
||||
});
|
||||
|
||||
test('should translate pm.collectionVariables.get', () => {
|
||||
const inputScript = 'pm.collectionVariables.get(\'key\');';
|
||||
const result = postmanTranslation(inputScript);
|
||||
expect(result).toContain('bru.getCollectionVar(\'key\')');
|
||||
});
|
||||
|
||||
test('should translate pm.expect with pm.environment.has', () => {
|
||||
const inputScript = 'pm.expect(pm.environment.has(\'key\')).to.be.true;';
|
||||
const result = postmanTranslation(inputScript);
|
||||
expect(result).toContain('bru.getEnvVar(\'key\') !== undefined && bru.getEnvVar(\'key\') !== null');
|
||||
expect(result).toContain('.to.be.true');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
test.skip('should translate pm.collectionVariables.set to bru.setCollectionVar', () => {
|
||||
const inputScript = 'pm.collectionVariables.set(\'key\', \'value\');';
|
||||
expect(postmanTranslation(inputScript)).toBe('bru.setCollectionVar(\'key\', \'value\');');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,13 +93,18 @@ describe('Combined API Features Translation', () => {
|
||||
expect(translatedCode).not.toContain('pm.test("Auth flow works", function() {');
|
||||
expect(translatedCode).not.toContain('pm.expect(response.authenticated).to.be.true;');
|
||||
expect(translatedCode).not.toContain('pm.environment.set("userId", response.user.id);');
|
||||
expect(translatedCode).not.toContain('pm.collectionVariables.set("sessionId", response.session.id);');
|
||||
expect(translatedCode).toContain('const token = bru.getEnvVar("authToken");');
|
||||
expect(translatedCode).toContain('test("Auth flow works", function() {');
|
||||
expect(translatedCode).toContain('const response = res.getBody();');
|
||||
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
|
||||
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", response.session.id);');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate pm.collectionVariables.set in a combined code block', () => {
|
||||
const code = 'pm.collectionVariables.set("sessionId", response.session.id);';
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toBe('bru.setCollectionVar("sessionId", response.session.id);');
|
||||
});
|
||||
|
||||
// Nested expressions
|
||||
@@ -109,7 +114,8 @@ describe('Combined API Features Translation', () => {
|
||||
expect(translatedCode).toBe('bru.setEnvVar("computed", bru.getVar("base") + "-suffix");');
|
||||
});
|
||||
|
||||
it('should handle more complex nested expressions', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should handle more complex nested expressions', () => {
|
||||
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toBe('bru.setCollectionVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
|
||||
@@ -347,14 +353,19 @@ describe('Combined API Features Translation', () => {
|
||||
|
||||
const translatedCode = translateCode(code);
|
||||
|
||||
expect(translatedCode).toBe(`
|
||||
function processResponse() {
|
||||
test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });
|
||||
bru.setEnvVar("userId", res.getBody().userId);
|
||||
bru.setVar("token", res.getBody().token);
|
||||
bru.setCollectionVar("sessionId", res.getBody().sessionId);
|
||||
}
|
||||
`);
|
||||
expect(translatedCode).toContain('test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });');
|
||||
expect(translatedCode).toContain('bru.setEnvVar("userId", res.getBody().userId);');
|
||||
expect(translatedCode).toContain('bru.setVar("token", res.getBody().token);');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate pm.collectionVariables alias set inside functions', () => {
|
||||
const code = `
|
||||
const tempCollVars = pm.collectionVariables;
|
||||
tempCollVars.set("sessionId", "value");
|
||||
`;
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", "value");');
|
||||
});
|
||||
|
||||
it('should nested pm commands', () => {
|
||||
|
||||
@@ -34,18 +34,23 @@ describe('Multiline Syntax Handling', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should handle multiline collection variable syntax', () => {
|
||||
it('should handle multiline collection variable get syntax', () => {
|
||||
const code = `
|
||||
const apiKey = pm.collectionVariables
|
||||
.get("apiKey");
|
||||
`;
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toContain('const apiKey = bru.getCollectionVar("apiKey")');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should handle multiline collection variable set syntax', () => {
|
||||
const code = `
|
||||
pm.collectionVariables
|
||||
.set("lastRun", new Date().toISOString());
|
||||
`;
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toBe(`
|
||||
const apiKey = bru.getCollectionVar("apiKey");
|
||||
bru.setCollectionVar("lastRun", new Date().toISOString());
|
||||
`);
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date().toISOString())');
|
||||
});
|
||||
|
||||
it('should handle complex environment.has transformation with multiline syntax', () => {
|
||||
@@ -193,7 +198,7 @@ describe('Multiline Syntax Handling', () => {
|
||||
it('should handle a comprehensive script with various multiline formats', () => {
|
||||
const code = `
|
||||
// This comprehensive script tests different multiline styles and whitespace variations
|
||||
|
||||
|
||||
// Environment variables with different formatting styles
|
||||
const baseUrl = pm.environment.get("baseUrl");
|
||||
const apiKey = pm
|
||||
@@ -201,39 +206,39 @@ describe('Multiline Syntax Handling', () => {
|
||||
.get("apiKey");
|
||||
const userId = pm.environment
|
||||
.get("userId");
|
||||
|
||||
|
||||
// Mix of variable styles
|
||||
pm.variables.set("testId", "test-" + Date.now());
|
||||
pm
|
||||
.variables
|
||||
.set("timestamp", new Date().toISOString());
|
||||
|
||||
|
||||
// Collection variables with inconsistent spacing
|
||||
pm.collectionVariables
|
||||
.set("lastRun", new Date());
|
||||
|
||||
|
||||
// Complex conditionals with multiline expressions
|
||||
if (pm
|
||||
.environment
|
||||
.has("apiKey") &&
|
||||
.has("apiKey") &&
|
||||
pm.variables.has("testId")) {
|
||||
|
||||
|
||||
// Testing response with mixed syntax styles
|
||||
pm.test("Response validation", function() {
|
||||
// Normal style
|
||||
pm.response.to.have.status(200);
|
||||
|
||||
|
||||
// Multiline with different indentation
|
||||
pm
|
||||
.response
|
||||
.to
|
||||
.have
|
||||
.header("content-type");
|
||||
|
||||
|
||||
pm.response
|
||||
.to.have
|
||||
.jsonBody("success", true);
|
||||
|
||||
|
||||
// Extreme indentation
|
||||
pm
|
||||
.response
|
||||
@@ -242,7 +247,7 @@ describe('Multiline Syntax Handling', () => {
|
||||
.have
|
||||
.jsonBody("error");
|
||||
});
|
||||
|
||||
|
||||
// Flow control with mixed styles
|
||||
if (pm.response.code === 401) {
|
||||
pm.execution.setNextRequest(null);
|
||||
@@ -264,9 +269,6 @@ describe('Multiline Syntax Handling', () => {
|
||||
expect(translatedCode).toContain('bru.setVar("testId", "test-" + Date.now())');
|
||||
expect(translatedCode).toContain('bru.setVar("timestamp", new Date().toISOString())');
|
||||
|
||||
// Check collection variables
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date())');
|
||||
|
||||
// Check complex conditionals
|
||||
expect(translatedCode).toContain('if (bru.getEnvVar("apiKey") !== undefined && bru.getEnvVar("apiKey") !== null &&');
|
||||
expect(translatedCode).toContain('bru.hasVar("testId"))');
|
||||
@@ -280,4 +282,14 @@ describe('Multiline Syntax Handling', () => {
|
||||
expect(translatedCode).toContain('bru.runner.stopExecution()');
|
||||
expect(translatedCode).toContain('bru.runner.setNextRequest("Next API Call")');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate multiline pm.collectionVariables.set in comprehensive script', () => {
|
||||
const code = `
|
||||
pm.collectionVariables
|
||||
.set("lastRun", new Date());
|
||||
`;
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("lastRun", new Date())');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +281,12 @@ describe('Response Translation', () => {
|
||||
const translatedCode = translateCode(code);
|
||||
|
||||
expect(translatedCode).toContain('const items = res.getBody().items;');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate pm.collectionVariables.set with array access pattern', () => {
|
||||
const code = 'pm.collectionVariables.set("item_" + i, items[i].id);';
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("item_" + i, items[i].id);');
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ describe('Testing Framework Translation', () => {
|
||||
expect(translatedCode).toContain('const response = res.getBody();');
|
||||
expect(translatedCode).toContain('expect(response.authenticated).to.be.true;');
|
||||
expect(translatedCode).toContain('bru.setEnvVar("userId", response.user.id);');
|
||||
});
|
||||
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate pm.collectionVariables.set inside test functions', () => {
|
||||
const code = 'pm.collectionVariables.set("sessionId", response.session.id);';
|
||||
const translatedCode = translateCode(code);
|
||||
expect(translatedCode).toContain('bru.setCollectionVar("sessionId", response.session.id);');
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ describe('Variables Translation', () => {
|
||||
expect(translatedCode).toBe('bru.getCollectionVar("apiUrl");');
|
||||
});
|
||||
|
||||
it('should translate pm.collectionVariables.set', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should translate pm.collectionVariables.set', () => {
|
||||
const code = 'pm.collectionVariables.set("token", jsonData.token);';
|
||||
const translatedCode = translateCode(code);
|
||||
|
||||
@@ -85,7 +86,8 @@ describe('Variables Translation', () => {
|
||||
expect(translatedCode).toBe('bru.hasCollectionVar("authToken");');
|
||||
});
|
||||
|
||||
it('should translate pm.collectionVariables.unset', () => {
|
||||
// TODO: Restore once UI update fixes are live for deleteCollectionVar
|
||||
it.skip('should translate pm.collectionVariables.unset', () => {
|
||||
const code = 'pm.collectionVariables.unset("tempVar");';
|
||||
const translatedCode = translateCode(code);
|
||||
|
||||
@@ -124,7 +126,8 @@ describe('Variables Translation', () => {
|
||||
});
|
||||
|
||||
// Alias tests for collection variables
|
||||
it('should handle collection variables aliases', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar/deleteCollectionVar
|
||||
it.skip('should handle collection variables aliases', () => {
|
||||
const code = `
|
||||
const collVars = pm.collectionVariables;
|
||||
const has = collVars.has("test");
|
||||
@@ -180,7 +183,8 @@ describe('Variables Translation', () => {
|
||||
expect(translatedCode).toContain('bru.setVar("requestTime", new Date().toISOString());');
|
||||
});
|
||||
|
||||
it('should handle all collection variable methods together', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar/deleteCollectionVar
|
||||
it.skip('should handle all collection variable methods together', () => {
|
||||
const code = `
|
||||
// All collection variable methods
|
||||
const hasApiUrl = pm.collectionVariables.has("apiUrl");
|
||||
@@ -198,7 +202,8 @@ describe('Variables Translation', () => {
|
||||
expect(translatedCode).toContain('bru.deleteCollectionVar("tempVar");');
|
||||
});
|
||||
|
||||
it('should handle more complex nested expressions with variables', () => {
|
||||
// TODO: Restore once UI update fixes are live for setCollectionVar
|
||||
it.skip('should handle more complex nested expressions with variables', () => {
|
||||
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
|
||||
const translatedCode = translateCode(code);
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* CollectionTreeBatcher - Batches IPC events to reduce Redux dispatch overhead.
|
||||
*
|
||||
* Instead of sending individual 'main:collection-tree-updated' events for each file,
|
||||
* this batcher collects events and sends them in batches, reducing the number of
|
||||
* Redux updates and improving UI performance during collection mounting.
|
||||
*
|
||||
* Flush triggers:
|
||||
* - Time-based: Every DISPATCH_INTERVAL_MS (200ms)
|
||||
* - Size-based: When batch reaches MAX_BATCH_SIZE (300 items)
|
||||
* - Manual: Call flush() directly (e.g., on watcher 'ready' event)
|
||||
*/
|
||||
|
||||
const DISPATCH_INTERVAL_MS = 200;
|
||||
const MAX_BATCH_SIZE = 200;
|
||||
|
||||
class CollectionTreeBatcher {
|
||||
constructor(win, collectionUid) {
|
||||
this.win = win;
|
||||
this.queue = [];
|
||||
this.timer = null;
|
||||
this.isDestroyed = false;
|
||||
// Bind methods
|
||||
// We need to bind the methods because these are being called as callbacks to
|
||||
// chokidar's add, addDir, change, unlink, unlinkDir events
|
||||
|
||||
this.add = this.add.bind(this);
|
||||
this.flush = this.flush.bind(this);
|
||||
this._scheduleFlush = this._scheduleFlush.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the window is still valid for sending events
|
||||
*/
|
||||
_isWindowValid() {
|
||||
return this.win && !this.win.isDestroyed() && !this.isDestroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush after the dispatch interval
|
||||
*/
|
||||
_scheduleFlush() {
|
||||
if (this.timer || !this._isWindowValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.flush();
|
||||
}, DISPATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the batch queue
|
||||
* @param {string} eventType - The event type ('addFile', 'addDir', 'change', 'unlink', 'unlinkDir')
|
||||
* @param {object} payload - The event payload
|
||||
*/
|
||||
add(eventType, payload) {
|
||||
if (!this._isWindowValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.queue.push({
|
||||
eventType,
|
||||
payload
|
||||
});
|
||||
|
||||
// Flush immediately if batch is full
|
||||
if (this.queue.length >= MAX_BATCH_SIZE) {
|
||||
this.flush();
|
||||
} else {
|
||||
this._scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the current batch to the renderer
|
||||
*/
|
||||
flush() {
|
||||
// Clear any pending timer
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
if (this.queue.length === 0 || !this._isWindowValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Take all items from the queue
|
||||
// This is a copy-type operation to avoid mutating the original
|
||||
// Splice returns the deleted items
|
||||
const batch = this.queue.splice(0);
|
||||
|
||||
try {
|
||||
// Send the batch to the renderer
|
||||
this.win.webContents.send('main:collection-tree-batch-updated', batch);
|
||||
} catch (error) {
|
||||
console.error('CollectionTreeBatcher: Error sending batch:', error);
|
||||
this.queue.push(...batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current queue size
|
||||
* @returns {number} - The number of items in the queue
|
||||
*/
|
||||
size() {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the queue without sending
|
||||
*/
|
||||
clear() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this batcher as destroyed (e.g., when window closes)
|
||||
*/
|
||||
destroy() {
|
||||
this.isDestroyed = true;
|
||||
this.clear();
|
||||
this.win = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Store for managing batchers per collection
|
||||
const batchers = new Map();
|
||||
|
||||
/**
|
||||
* Get the batcher key for a window and collection UID
|
||||
* @param {BrowserWindow} win - The Electron BrowserWindow
|
||||
* @param {string} collectionUid - The collection UID
|
||||
* @returns {string} - The batcher key
|
||||
*/
|
||||
const getBatcherKey = (win, collectionUid) => {
|
||||
return `${win.id}-${collectionUid}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or create a CollectionTreeBatcher for a window
|
||||
* @param {BrowserWindow} win - The Electron BrowserWindow
|
||||
* @param {string} collectionUid - The collection UID
|
||||
* @returns {CollectionTreeBatcher} - The batcher instance
|
||||
*/
|
||||
const getBatcher = (win, collectionUid) => {
|
||||
const batcherKey = getBatcherKey(win, collectionUid);
|
||||
|
||||
if (!batchers.has(batcherKey)) {
|
||||
const batcher = new CollectionTreeBatcher(win, collectionUid);
|
||||
|
||||
// Clean up when window is closed
|
||||
win.once('closed', () => {
|
||||
const b = batchers.get(batcherKey);
|
||||
if (b) {
|
||||
b.destroy();
|
||||
batchers.delete(batcherKey);
|
||||
}
|
||||
});
|
||||
|
||||
batchers.set(batcherKey, batcher);
|
||||
}
|
||||
|
||||
return batchers.get(batcherKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a batcher for a window
|
||||
* @param {BrowserWindow} win - The Electron BrowserWindow
|
||||
* @param {string} collectionUid - The collection UID
|
||||
*/
|
||||
const removeBatcher = (win, collectionUid) => {
|
||||
const batcherKey = getBatcherKey(win, collectionUid);
|
||||
const batcher = batchers.get(batcherKey);
|
||||
|
||||
if (batcher) {
|
||||
batcher.destroy();
|
||||
batchers.delete(batcherKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Export with backward-compatible aliases
|
||||
module.exports = {
|
||||
CollectionTreeBatcher,
|
||||
getBatcher,
|
||||
removeBatcher,
|
||||
// Backward-compatible aliases
|
||||
BatchAggregator: CollectionTreeBatcher,
|
||||
getAggregator: getBatcher,
|
||||
removeAggregator: removeBatcher,
|
||||
constants: {
|
||||
MAX_BATCH_SIZE,
|
||||
DISPATCH_INTERVAL_MS
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
const _ = require('lodash');
|
||||
const fs = require('fs');
|
||||
const fsPromises = require('fs').promises;
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const {
|
||||
@@ -27,8 +26,6 @@ const UiStateSnapshot = require('../store/ui-state-snapshot');
|
||||
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
|
||||
const { parseLargeRequestWithRedaction } = require('../utils/parse');
|
||||
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
|
||||
const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
|
||||
const { getBatcher } = require('./collection-tree-batcher');
|
||||
const dotEnvWatcher = require('./dotenv-watcher');
|
||||
|
||||
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
|
||||
@@ -226,8 +223,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
|
||||
}
|
||||
|
||||
const batcher = getBatcher(win, collectionUid);
|
||||
|
||||
if (isCollectionRootFile(pathname, collectionPath)) {
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
const file = {
|
||||
@@ -294,8 +289,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
file.data = await parseFolder(content, { format });
|
||||
|
||||
hydrateCollectionRootWithUuid(file.data);
|
||||
// win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
batcher.add('addFile', file);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -315,69 +309,61 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const fileStats = await fsPromises.stat(pathname);
|
||||
const fileStats = fs.statSync(pathname);
|
||||
let content = fs.readFileSync(pathname, 'utf8');
|
||||
|
||||
const cachedEntry = await parsedFileCacheStore.getEntry(collectionPath, pathname);
|
||||
if (cachedEntry && cachedEntry.mtimeMs === fileStats.mtimeMs) {
|
||||
// Cache hit
|
||||
file.data = cachedEntry.parsedData;
|
||||
file.partial = false;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
batcher.add('addFile', file);
|
||||
watcher.markFileAsProcessed(win, collectionUid, pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache miss
|
||||
const content = await fsPromises.readFile(pathname, 'utf8');
|
||||
|
||||
if (!useWorkerThread) {
|
||||
// If worker thread is not used, we can directly parse the file
|
||||
if (!useWorkerThread) {
|
||||
try {
|
||||
file.data = await parseRequest(content, { format });
|
||||
file.partial = false;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
batcher.add('addFile', file);
|
||||
|
||||
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
parsedData: file.data
|
||||
});
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
watcher.markFileAsProcessed(win, collectionUid, pathname);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// we need to send a partial file info to the UI
|
||||
// so that the UI can display the file in the collection tree
|
||||
file.data = {
|
||||
name: path.basename(pathname),
|
||||
type: 'http-request'
|
||||
};
|
||||
|
||||
const metaJson = parseFileMeta(content, format);
|
||||
file.data = metaJson;
|
||||
file.partial = true;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
|
||||
if (fileStats.size < MAX_FILE_SIZE) {
|
||||
// This is to update the loading indicator in the UI
|
||||
file.data = metaJson;
|
||||
file.partial = false;
|
||||
file.loading = true;
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
|
||||
// This is to update the file info in the UI
|
||||
file.data = await parseRequestViaWorker(content, {
|
||||
format,
|
||||
filename: pathname
|
||||
});
|
||||
file.partial = false;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
batcher.add('addFile', file);
|
||||
|
||||
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
parsedData: file.data
|
||||
});
|
||||
} else {
|
||||
const metaJson = parseFileMeta(content, format);
|
||||
file.data = metaJson;
|
||||
file.partial = true;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
batcher.add('addFile', file);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
}
|
||||
|
||||
watcher.markFileAsProcessed(win, collectionUid, pathname);
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${pathname}:`, error);
|
||||
file.data = {
|
||||
name: path.basename(pathname),
|
||||
type: 'http-request'
|
||||
@@ -387,8 +373,10 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
|
||||
};
|
||||
file.partial = true;
|
||||
file.loading = false;
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
batcher.add('addFile', file);
|
||||
win.webContents.send('main:collection-tree-updated', 'addFile', file);
|
||||
} finally {
|
||||
watcher.markFileAsProcessed(win, collectionUid, pathname);
|
||||
}
|
||||
}
|
||||
@@ -408,16 +396,15 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
|
||||
const folderFilePath = path.join(pathname, `folder.${format}`);
|
||||
|
||||
try {
|
||||
await fsPromises.access(folderFilePath);
|
||||
const folderFileContent = await fsPromises.readFile(folderFilePath, 'utf8');
|
||||
const folderData = await parseFolder(folderFileContent, { format });
|
||||
name = folderData?.meta?.name || name;
|
||||
seq = folderData?.meta?.seq;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error(`Error occurred while parsing folder.${format} file`);
|
||||
console.error(error);
|
||||
if (fs.existsSync(folderFilePath)) {
|
||||
let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
|
||||
let folderData = await parseFolder(folderFileContent, { format });
|
||||
name = folderData?.meta?.name || name;
|
||||
seq = folderData?.meta?.seq;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error occured while parsing folder.${format} file`);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const directory = {
|
||||
@@ -430,8 +417,7 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
|
||||
}
|
||||
};
|
||||
|
||||
const batcher = getBatcher(win, collectionUid);
|
||||
batcher.add('addDir', directory);
|
||||
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
|
||||
};
|
||||
|
||||
const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
@@ -538,9 +524,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
// Invalidate cache for this file since it changed
|
||||
await parsedFileCacheStore.invalidate(collectionPath, pathname);
|
||||
|
||||
try {
|
||||
const file = {
|
||||
meta: {
|
||||
@@ -561,14 +544,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
|
||||
file.size = sizeInMB(fileStats?.size);
|
||||
hydrateRequestWithUuid(file.data, pathname);
|
||||
|
||||
// Update cache with new parsed data
|
||||
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
parsedData: file.data
|
||||
});
|
||||
|
||||
// Change events are not batched - they need immediate feedback
|
||||
win.webContents.send('main:collection-tree-updated', 'change', file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -576,7 +551,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
||||
}
|
||||
};
|
||||
|
||||
const unlink = async (win, pathname, collectionUid, collectionPath) => {
|
||||
const unlink = (win, pathname, collectionUid, collectionPath) => {
|
||||
console.log(`watcher unlink: ${pathname}`);
|
||||
|
||||
if (isEnvironmentsFolder(pathname, collectionPath)) {
|
||||
@@ -585,9 +560,6 @@ const unlink = async (win, pathname, collectionUid, collectionPath) => {
|
||||
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
// Invalidate cache for deleted file
|
||||
await parsedFileCacheStore.invalidate(collectionPath, pathname);
|
||||
|
||||
const basename = path.basename(pathname);
|
||||
const dirname = path.dirname(pathname);
|
||||
|
||||
@@ -613,8 +585,6 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await parsedFileCacheStore.invalidateDirectory(collectionPath, pathname);
|
||||
|
||||
const format = getCollectionFormat(collectionPath);
|
||||
const folderFilePath = path.join(pathname, `folder.${format}`);
|
||||
|
||||
@@ -637,9 +607,7 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
|
||||
};
|
||||
|
||||
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
|
||||
const batcher = getBatcher(win, collectionUid);
|
||||
batcher.flush();
|
||||
|
||||
// Mark discovery as complete
|
||||
watcher.completeCollectionDiscovery(win, collectionUid);
|
||||
|
||||
const UiStateSnapshotStore = new UiStateSnapshot();
|
||||
|
||||
@@ -39,7 +39,6 @@ const registerNetworkIpc = require('./ipc/network');
|
||||
const registerCollectionsIpc = require('./ipc/collection');
|
||||
const registerFilesystemIpc = require('./ipc/filesystem');
|
||||
const registerPreferencesIpc = require('./ipc/preferences');
|
||||
const { parsedFileCacheStore } = require('./store/parsed-file-cache-idb');
|
||||
const registerSystemMonitorIpc = require('./ipc/system-monitor');
|
||||
const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
@@ -461,9 +460,6 @@ app.on('ready', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize the parsed file cache IPC handlers
|
||||
parsedFileCacheStore.initialize(mainWindow);
|
||||
|
||||
// register all ipc handlers
|
||||
registerNetworkIpc(mainWindow);
|
||||
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
isFile,
|
||||
isDirectory
|
||||
} = require('../utils/filesystem');
|
||||
const { findUniqueFolderName } = require('../utils/collection-import');
|
||||
|
||||
const registerFilesystemIpc = (mainWindow) => {
|
||||
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
|
||||
@@ -47,6 +48,14 @@ const registerFilesystemIpc = (mainWindow) => {
|
||||
ipcMain.handle('renderer:is-directory', async (_, pathname) => {
|
||||
return isDirectory(pathname);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:find-unique-folder-name', async (_, baseName, location) => {
|
||||
try {
|
||||
return await findUniqueFolderName(baseName, location);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = registerFilesystemIpc;
|
||||
|
||||
@@ -194,7 +194,8 @@ const buildCertsAndProxyConfig = async ({
|
||||
shouldVerifyTls: preferencesUtil.shouldVerifyTls(),
|
||||
shouldUseCustomCaCertificate: preferencesUtil.shouldUseCustomCaCertificate(),
|
||||
customCaCertificateFilePath: preferencesUtil.getCustomCaCertificateFilePath(),
|
||||
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates()
|
||||
shouldKeepDefaultCaCertificates: preferencesUtil.shouldKeepDefaultCaCertificates(),
|
||||
cacheSslSession: preferencesUtil.isSslSessionCachingEnabled()
|
||||
};
|
||||
|
||||
// Get client certificates from bruno config and interpolate
|
||||
|
||||
@@ -2,11 +2,11 @@ const { ipcMain, nativeTheme } = require('electron');
|
||||
const { getPreferences, savePreferences } = require('../store/preferences');
|
||||
const { getGitVersion } = require('../utils/git');
|
||||
const { globalEnvironmentsStore } = require('../store/global-environments');
|
||||
const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
|
||||
const { getCachedSystemProxy, fetchSystemProxy } = require('../store/system-proxy');
|
||||
const { resolveDefaultLocation } = require('../utils/default-location');
|
||||
const onboardUser = require('../app/onboarding');
|
||||
const LastOpenedCollections = require('../store/last-opened-collections');
|
||||
const { clearAgentCache } = require('@usebruno/requests');
|
||||
|
||||
const registerPreferencesIpc = (mainWindow) => {
|
||||
const lastOpenedCollections = new LastOpenedCollections();
|
||||
@@ -57,29 +57,18 @@ const registerPreferencesIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:clear-http-https-agent-cache', async () => {
|
||||
try {
|
||||
clearAgentCache();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:theme-change', (event, theme) => {
|
||||
nativeTheme.themeSource = theme;
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-cache-stats', async () => {
|
||||
try {
|
||||
return await parsedFileCacheStore.getStats();
|
||||
} catch (error) {
|
||||
console.error('Error getting cache stats:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:purge-cache', async () => {
|
||||
try {
|
||||
await parsedFileCacheStore.clear();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error purging cache:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-system-proxy-variables', async () => {
|
||||
return await getCachedSystemProxy();
|
||||
});
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// Pending requests waiting for renderer response
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// Timeout for IPC requests (5 seconds)
|
||||
const REQUEST_TIMEOUT = 5000;
|
||||
|
||||
// Store reference to main window
|
||||
let mainWindow = null;
|
||||
|
||||
// Initialize the IPC response handler
|
||||
const initializeCacheIpc = (win) => {
|
||||
mainWindow = win;
|
||||
|
||||
ipcMain.on('renderer:parsed-file-cache-response', (event, response) => {
|
||||
const { requestId, success, data, error } = response;
|
||||
const pending = pendingRequests.get(requestId);
|
||||
|
||||
if (pending) {
|
||||
pendingRequests.delete(requestId);
|
||||
clearTimeout(pending.timeout);
|
||||
|
||||
if (success) {
|
||||
pending.resolve(data);
|
||||
} else {
|
||||
pending.reject(new Error(error || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Send a request to the renderer and wait for response
|
||||
const sendCacheRequest = (operation, ...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = uuidv4();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve(null);
|
||||
}, REQUEST_TIMEOUT);
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
mainWindow.webContents.send('main:parsed-file-cache-request', operation, requestId, ...args);
|
||||
});
|
||||
};
|
||||
|
||||
class ParsedFileCacheStore {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
initialize(win) {
|
||||
if (!this.initialized) {
|
||||
initializeCacheIpc(win);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
async getEntry(collectionPath, filePath) {
|
||||
try {
|
||||
return await sendCacheRequest('getEntry', collectionPath, filePath);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error reading cache entry:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setEntry(collectionPath, filePath, entry) {
|
||||
try {
|
||||
await sendCacheRequest('setEntry', collectionPath, filePath, entry);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error writing cache entry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async invalidate(collectionPath, filePath) {
|
||||
try {
|
||||
await sendCacheRequest('invalidate', collectionPath, filePath);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateCollection(collectionPath) {
|
||||
try {
|
||||
await sendCacheRequest('invalidateCollection', collectionPath);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateDirectory(collectionPath, dirPath) {
|
||||
try {
|
||||
await sendCacheRequest('invalidateDirectory', collectionPath, dirPath);
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async moveEntry(collectionPath, oldFilePath, newFilePath) {
|
||||
const entry = await this.getEntry(collectionPath, oldFilePath);
|
||||
if (entry) {
|
||||
await this.invalidate(collectionPath, oldFilePath);
|
||||
await this.setEntry(collectionPath, newFilePath, {
|
||||
mtimeMs: entry.mtimeMs,
|
||||
parsedData: entry.parsedData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
try {
|
||||
const stats = await sendCacheRequest('getStats');
|
||||
return stats || {
|
||||
version: '1.0.0',
|
||||
totalCollections: 0,
|
||||
totalFiles: 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error getting stats:', error);
|
||||
return {
|
||||
version: '1.0.0',
|
||||
totalCollections: 0,
|
||||
totalFiles: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
await sendCacheRequest('clear');
|
||||
} catch (error) {
|
||||
console.error('ParsedFileCacheStore: Error clearing cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
// No-op for IndexedDB version (managed by browser)
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const parsedFileCacheStore = new ParsedFileCacheStore();
|
||||
|
||||
module.exports = {
|
||||
parsedFileCacheStore,
|
||||
ParsedFileCacheStore
|
||||
};
|
||||
@@ -106,6 +106,11 @@ const defaultPreferences = {
|
||||
},
|
||||
display: {
|
||||
zoomPercentage: 100
|
||||
},
|
||||
cache: {
|
||||
sslSession: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +169,12 @@ const preferencesSchema = Yup.object().shape({
|
||||
}),
|
||||
display: Yup.object({
|
||||
zoomPercentage: Yup.number().min(50).max(150)
|
||||
})
|
||||
}),
|
||||
cache: Yup.object({
|
||||
sslSession: Yup.object({
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
}).optional()
|
||||
});
|
||||
|
||||
class PreferencesStore {
|
||||
@@ -351,6 +361,9 @@ const preferencesUtil = {
|
||||
getZoomPercentage: () => {
|
||||
return get(getPreferences(), 'display.zoomPercentage', 100);
|
||||
},
|
||||
isSslSessionCachingEnabled: () => {
|
||||
return get(getPreferences(), 'cache.sslSession.enabled', false);
|
||||
},
|
||||
hasLaunchedBefore: () => {
|
||||
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
|
||||
},
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const parseUrl = require('url').parse;
|
||||
const https = require('node:https');
|
||||
const http = require('node:http');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { interpolateString } = require('../ipc/network/interpolate-string');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { HttpProxyAgent } = require('http-proxy-agent');
|
||||
const { isEmpty, get, isUndefined, isNull } = require('lodash');
|
||||
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
ftp: 21,
|
||||
@@ -67,9 +70,17 @@ const shouldUseProxy = (url, proxyBypass) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent to get around a bug that ignores options
|
||||
* such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
|
||||
* https://github.com/TooTallNate/proxy-agents/issues/194
|
||||
* Options that should be forwarded from the constructor to the target TLS upgrade.
|
||||
*/
|
||||
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'];
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent that correctly handles TLS options for
|
||||
* both the proxy connection and the target server connection.
|
||||
*
|
||||
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
|
||||
* ignores constructor options when upgrading the tunneled socket to TLS for the
|
||||
* target server. This patch forwards the relevant TLS options to the target upgrade.
|
||||
*/
|
||||
class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
constructor(proxy, opts) {
|
||||
@@ -78,244 +89,20 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
}
|
||||
|
||||
async connect(req, opts) {
|
||||
const combinedOpts = { ...this.constructorOpts, ...opts };
|
||||
return super.connect(req, combinedOpts);
|
||||
const targetOpts = { ...opts };
|
||||
|
||||
if (this.constructorOpts) {
|
||||
for (const key of TARGET_TLS_OPTIONS) {
|
||||
if (key in this.constructorOpts) {
|
||||
targetOpts[key] = this.constructorOpts[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.connect(req, targetOpts);
|
||||
}
|
||||
}
|
||||
|
||||
function createTimelineHttpAgentClass(BaseAgentClass) {
|
||||
return class extends BaseAgentClass {
|
||||
constructor(options, timeline) {
|
||||
// For proxy agents, the first argument is the proxy URI and the second is options
|
||||
const { proxy: proxyUri, httpProxyAgentOptions } = options || {};
|
||||
|
||||
if (!proxyUri) {
|
||||
throw new Error('TimelineHttpProxyAgent requires options.proxy to be set');
|
||||
}
|
||||
|
||||
super(proxyUri, httpProxyAgentOptions);
|
||||
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
// Log the proxy details
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using proxy: ${proxyUri}`
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTimelineAgentClass(BaseAgentClass) {
|
||||
return class extends BaseAgentClass {
|
||||
constructor(options, timeline) {
|
||||
let caCertificatesCount = options.caCertificatesCount || {};
|
||||
delete options.caCertificatesCount;
|
||||
|
||||
// For proxy agents, the first argument is the proxy URI and the second is options
|
||||
if (options?.proxy) {
|
||||
const { proxy: proxyUri, ...agentOptions } = options;
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions = {
|
||||
...agentOptions,
|
||||
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
|
||||
};
|
||||
super(proxyUri, tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!tlsOptions.ca;
|
||||
|
||||
// Log TLS verification status
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
|
||||
});
|
||||
|
||||
// Log the proxy details
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using proxy: ${proxyUri}`
|
||||
});
|
||||
} else {
|
||||
// This is a regular HTTPS agent case
|
||||
const tlsOptions = {
|
||||
...options,
|
||||
rejectUnauthorized: options.rejectUnauthorized ?? true
|
||||
};
|
||||
super(tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = options.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!options.ca;
|
||||
|
||||
// Log TLS verification status
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`
|
||||
});
|
||||
}
|
||||
|
||||
this.caCertificatesCount = caCertificatesCount;
|
||||
}
|
||||
|
||||
createConnection(options, callback) {
|
||||
const { host, port } = options;
|
||||
|
||||
// Log ALPN protocols offered
|
||||
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `ALPN: offers ${this.alpnProtocols.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
const rootCerts = this.caCertificatesCount.root || 0;
|
||||
const systemCerts = this.caCertificatesCount.system || 0;
|
||||
const extraCerts = this.caCertificatesCount.extra || 0;
|
||||
const customCerts = this.caCertificatesCount.custom || 0;
|
||||
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`
|
||||
});
|
||||
|
||||
// Log "Trying host:port..."
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Trying ${host}:${port}...`
|
||||
});
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = super.createConnection(options, callback);
|
||||
} catch (error) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
message: `Error creating connection: ${error.message}`
|
||||
});
|
||||
error.timeline = this.timeline;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Attach event listeners to the socket
|
||||
socket?.on('lookup', (err, address, family, host) => {
|
||||
if (err) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
message: `DNS lookup error for ${host}: ${err.message}`
|
||||
});
|
||||
} else {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `DNS lookup: ${host} -> ${address}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on('connect', () => {
|
||||
const address = socket.remoteAddress || host;
|
||||
const remotePort = socket.remotePort || port;
|
||||
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Connected to ${host} (${address}) port ${remotePort}`
|
||||
});
|
||||
});
|
||||
|
||||
socket?.on('secureConnect', () => {
|
||||
const protocol = socket.getProtocol() || 'SSL/TLS';
|
||||
const cipher = socket.getCipher();
|
||||
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
|
||||
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `SSL connection using ${protocol} / ${cipherSuite}`
|
||||
});
|
||||
|
||||
// ALPN protocol
|
||||
const alpnProtocol = socket.alpnProtocol || 'None';
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `ALPN: server accepted ${alpnProtocol}`
|
||||
});
|
||||
|
||||
// Server certificate
|
||||
const cert = socket.getPeerCertificate(true);
|
||||
if (cert) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `Server certificate:`
|
||||
});
|
||||
if (cert.subject) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`
|
||||
});
|
||||
}
|
||||
if (cert.valid_from) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: ` start date: ${cert.valid_from}`
|
||||
});
|
||||
}
|
||||
if (cert.valid_to) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: ` expire date: ${cert.valid_to}`
|
||||
});
|
||||
}
|
||||
if (cert.subjectaltname) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: ` subjectAltName: ${cert.subjectaltname}`
|
||||
});
|
||||
}
|
||||
if (cert.issuer) {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// SSL certificate verify ok
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'tls',
|
||||
message: `SSL certificate verify ok.`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on('error', (err) => {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
message: `Socket error: ${err.message}`
|
||||
});
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode = 'off',
|
||||
@@ -324,6 +111,8 @@ function setupProxyAgents({
|
||||
interpolationOptions,
|
||||
timeline
|
||||
}) {
|
||||
const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
|
||||
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions = {
|
||||
...httpsAgentRequestFields,
|
||||
@@ -331,21 +120,22 @@ function setupProxyAgents({
|
||||
secureProtocol: undefined,
|
||||
// Allow Node.js to choose the protocol
|
||||
minVersion: 'TLSv1',
|
||||
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true
|
||||
};
|
||||
|
||||
const httpProxyAgentOptions = {
|
||||
rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true,
|
||||
// Enable keepAlive for connection reuse
|
||||
keepAlive: true
|
||||
};
|
||||
|
||||
const parsedUrl = parseUrl(requestConfig.url);
|
||||
const isHttpsRequest = parsedUrl.protocol === 'https:';
|
||||
const hostname = parsedUrl.hostname || null;
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (shouldProxy) {
|
||||
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
|
||||
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
|
||||
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
|
||||
const proxyAuthDisabled = get(proxyConfig, 'auth.disabled', false);
|
||||
const proxyAuthEnabled = !proxyAuthDisabled;
|
||||
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
|
||||
const socksEnabled = proxyProtocol.includes('socks');
|
||||
|
||||
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
|
||||
@@ -358,35 +148,51 @@ function setupProxyAgents({
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
|
||||
if (socksEnabled) {
|
||||
const TimelineSocksProxyAgent = createTimelineAgentClass(SocksProxyAgent);
|
||||
requestConfig.httpAgent = new TimelineSocksProxyAgent({ proxy: proxyUri }, timeline);
|
||||
requestConfig.httpsAgent = new TimelineSocksProxyAgent({ proxy: proxyUri, ...tlsOptions }, timeline);
|
||||
} else {
|
||||
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
|
||||
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
|
||||
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: proxyUri, httpProxyAgentOptions }, timeline);
|
||||
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
|
||||
{ proxy: proxyUri, ...tlsOptions },
|
||||
timeline
|
||||
);
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using proxy: ${proxyProtocol}://${proxyHostname}${uriPort}`
|
||||
});
|
||||
}
|
||||
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
||||
|
||||
// Only set the agent needed for the request protocol
|
||||
if (socksEnabled) {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, timeline, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If proxy should not be used, set default HTTPS agent
|
||||
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
|
||||
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
const { http_proxy, https_proxy, no_proxy } = proxyConfig || {};
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
|
||||
const parsedUrl = parseUrl(requestConfig.url);
|
||||
const isHttpsRequest = parsedUrl.protocol === 'https:';
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
new URL(http_proxy);
|
||||
const TimelineHttpProxyAgent = createTimelineHttpAgentClass(HttpProxyAgent);
|
||||
requestConfig.httpAgent = new TimelineHttpProxyAgent({ proxy: http_proxy, httpProxyAgentOptions }, timeline);
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using system proxy: ${http_proxy}`
|
||||
});
|
||||
}
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, timeline, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid system http_proxy "${http_proxy}": ${error.message}`);
|
||||
@@ -394,25 +200,27 @@ function setupProxyAgents({
|
||||
try {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
const TimelineHttpsProxyAgent = createTimelineAgentClass(PatchedHttpsProxyAgent);
|
||||
requestConfig.httpsAgent = new TimelineHttpsProxyAgent(
|
||||
{ proxy: https_proxy, ...tlsOptions },
|
||||
timeline
|
||||
);
|
||||
} else {
|
||||
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
|
||||
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: `Using system proxy: ${https_proxy}`
|
||||
});
|
||||
}
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, timeline, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
|
||||
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
|
||||
}
|
||||
} else {
|
||||
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
|
||||
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
|
||||
}
|
||||
|
||||
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
|
||||
if (isHttpsRequest) {
|
||||
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, proxyUri: null, timeline, disableCache, hostname });
|
||||
} else {
|
||||
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, proxyUri: null, timeline, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
const { CollectionTreeBatcher, getBatcher, removeBatcher, constants } = require('../../src/app/collection-tree-batcher');
|
||||
|
||||
// Mock BrowserWindow
|
||||
const createMockWindow = (id = 1) => {
|
||||
const listeners = {};
|
||||
return {
|
||||
id,
|
||||
isDestroyed: jest.fn(() => false),
|
||||
once: jest.fn((event, callback) => {
|
||||
listeners[event] = callback;
|
||||
}),
|
||||
emit: (event) => {
|
||||
if (listeners[event]) {
|
||||
listeners[event]();
|
||||
}
|
||||
},
|
||||
webContents: {
|
||||
send: jest.fn()
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
describe('CollectionTreeBatcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with empty queue and no timer', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
expect(batcher.queue).toEqual([]);
|
||||
expect(batcher.timer).toBeNull();
|
||||
expect(batcher.isDestroyed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add()', () => {
|
||||
it('should add events to the queue', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
expect(batcher.queue).toHaveLength(1);
|
||||
expect(batcher.queue[0]).toEqual({
|
||||
eventType: 'addFile',
|
||||
payload: { path: '/test/file.bru' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should schedule a flush after adding an event', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
expect(batcher.timer).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not add events if window is destroyed', () => {
|
||||
const win = createMockWindow();
|
||||
win.isDestroyed.mockReturnValue(true);
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not add events if batcher is destroyed', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
batcher.destroy();
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush()', () => {
|
||||
it('should send batch to renderer and clear queue', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file1.bru' });
|
||||
batcher.add('addDir', { path: '/test/folder' });
|
||||
|
||||
batcher.flush();
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
|
||||
{ eventType: 'addFile', payload: { path: '/test/file1.bru' } },
|
||||
{ eventType: 'addDir', payload: { path: '/test/folder' } }
|
||||
]);
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not send if queue is empty', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.flush();
|
||||
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear pending timer on flush', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
expect(batcher.timer).not.toBeNull();
|
||||
|
||||
batcher.flush();
|
||||
expect(batcher.timer).toBeNull();
|
||||
});
|
||||
|
||||
it('should not send if window is destroyed', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
win.isDestroyed.mockReturnValue(true);
|
||||
|
||||
batcher.flush();
|
||||
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('time-based flush', () => {
|
||||
it('should auto-flush after DISPATCH_INTERVAL_MS (200ms)', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
|
||||
{ eventType: 'addFile', payload: { path: '/test/file.bru' } }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not schedule multiple timers', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file1.bru' });
|
||||
const firstTimer = batcher.timer;
|
||||
|
||||
batcher.add('addFile', { path: '/test/file2.bru' });
|
||||
|
||||
expect(batcher.timer).toBe(firstTimer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size-based flush', () => {
|
||||
it('should auto-flush when reaching MAX_BATCH_SIZE', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
const eventCount = constants.MAX_BATCH_SIZE - 1;
|
||||
// Add events - should not flush
|
||||
for (let i = 0; i < eventCount; i++) {
|
||||
batcher.add('addFile', { path: `/test/file${i}.bru` });
|
||||
}
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
expect(batcher.queue).toHaveLength(eventCount);
|
||||
|
||||
// Add 300th event - should trigger flush
|
||||
batcher.add('addFile', { path: '/test/file299.bru' });
|
||||
|
||||
expect(win.webContents.send).toHaveBeenCalledTimes(1);
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size()', () => {
|
||||
it('should return current queue size', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
expect(batcher.size()).toBe(0);
|
||||
|
||||
batcher.add('addFile', { path: '/test/file1.bru' });
|
||||
expect(batcher.size()).toBe(1);
|
||||
|
||||
batcher.add('addFile', { path: '/test/file2.bru' });
|
||||
expect(batcher.size()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('should clear the queue without sending', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
batcher.clear();
|
||||
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
expect(batcher.timer).toBeNull();
|
||||
expect(win.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('should mark batcher as destroyed and clear queue', () => {
|
||||
const win = createMockWindow();
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
batcher.destroy();
|
||||
|
||||
expect(batcher.isDestroyed).toBe(true);
|
||||
expect(batcher.queue).toHaveLength(0);
|
||||
expect(batcher.win).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle send errors gracefully', () => {
|
||||
const win = createMockWindow();
|
||||
win.webContents.send.mockImplementation(() => {
|
||||
throw new Error('Window closed');
|
||||
});
|
||||
const batcher = new CollectionTreeBatcher(win, 'collection-1');
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
batcher.flush();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('CollectionTreeBatcher: Error sending batch:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBatcher / removeBatcher', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should create and return a batcher for a window', () => {
|
||||
const win = createMockWindow(100);
|
||||
const batcher = getBatcher(win, 'collection-1');
|
||||
|
||||
expect(batcher).toBeInstanceOf(CollectionTreeBatcher);
|
||||
});
|
||||
|
||||
it('should return the same batcher for the same window and collection', () => {
|
||||
const win = createMockWindow(101);
|
||||
const batcher1 = getBatcher(win, 'collection-1');
|
||||
const batcher2 = getBatcher(win, 'collection-1');
|
||||
|
||||
expect(batcher1).toBe(batcher2);
|
||||
});
|
||||
|
||||
it('should return different batchers for different collections', () => {
|
||||
const win = createMockWindow(102);
|
||||
const batcher1 = getBatcher(win, 'collection-1');
|
||||
const batcher2 = getBatcher(win, 'collection-2');
|
||||
|
||||
expect(batcher1).not.toBe(batcher2);
|
||||
});
|
||||
|
||||
it('should return different batchers for different windows', () => {
|
||||
const win1 = createMockWindow(103);
|
||||
const win2 = createMockWindow(104);
|
||||
const batcher1 = getBatcher(win1, 'collection-1');
|
||||
const batcher2 = getBatcher(win2, 'collection-1');
|
||||
|
||||
expect(batcher1).not.toBe(batcher2);
|
||||
});
|
||||
|
||||
it('should clean up batcher when window is closed', () => {
|
||||
const win = createMockWindow(105);
|
||||
const batcher = getBatcher(win, 'collection-1');
|
||||
|
||||
batcher.add('addFile', { path: '/test/file.bru' });
|
||||
|
||||
// Simulate window close
|
||||
win.emit('closed');
|
||||
|
||||
expect(batcher.isDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove batcher with removeBatcher', () => {
|
||||
const win = createMockWindow(106);
|
||||
const batcher = getBatcher(win, 'collection-1');
|
||||
|
||||
removeBatcher(win, 'collection-1');
|
||||
|
||||
expect(batcher.isDestroyed).toBe(true);
|
||||
|
||||
// Getting batcher again should create a new one
|
||||
const newBatcher = getBatcher(win, 'collection-1');
|
||||
expect(newBatcher).not.toBe(batcher);
|
||||
});
|
||||
});
|
||||
@@ -257,21 +257,25 @@ class Bru {
|
||||
this.globalEnvironmentVariables[key] = value;
|
||||
}
|
||||
|
||||
deleteGlobalEnvVar(key) {
|
||||
delete this.globalEnvironmentVariables[key];
|
||||
}
|
||||
// TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// deleteGlobalEnvVar(key) {
|
||||
// delete this.globalEnvironmentVariables[key];
|
||||
// }
|
||||
|
||||
getAllGlobalEnvVars() {
|
||||
return Object.assign({}, this.globalEnvironmentVariables);
|
||||
}
|
||||
|
||||
deleteAllGlobalEnvVars() {
|
||||
for (let key in this.globalEnvironmentVariables) {
|
||||
if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
|
||||
delete this.globalEnvironmentVariables[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// deleteAllGlobalEnvVars() {
|
||||
// for (let key in this.globalEnvironmentVariables) {
|
||||
// if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
|
||||
// delete this.globalEnvironmentVariables[key];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
getOauth2CredentialVar(key) {
|
||||
return this.interpolate(this.oauth2CredentialVariables[key]);
|
||||
@@ -345,40 +349,48 @@ class Bru {
|
||||
return this.interpolate(this.collectionVariables[key]);
|
||||
}
|
||||
|
||||
setCollectionVar(key, value) {
|
||||
if (!key) {
|
||||
throw new Error('Creating a variable without specifying a name is not allowed.');
|
||||
}
|
||||
|
||||
if (variableNameRegex.test(key) === false) {
|
||||
throw new Error(
|
||||
`Variable name: "${key}" contains invalid characters!`
|
||||
+ ' Names must only contain alpha-numeric characters, "-", "_", "."'
|
||||
);
|
||||
}
|
||||
|
||||
this.collectionVariables[key] = value;
|
||||
}
|
||||
// TODO: setCollectionVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// setCollectionVar(key, value) {
|
||||
// if (!key) {
|
||||
// throw new Error('Creating a variable without specifying a name is not allowed.');
|
||||
// }
|
||||
//
|
||||
// if (variableNameRegex.test(key) === false) {
|
||||
// throw new Error(
|
||||
// `Variable name: "${key}" contains invalid characters!`
|
||||
// + ' Names must only contain alpha-numeric characters, "-", "_", "."'
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// this.collectionVariables[key] = value;
|
||||
// }
|
||||
|
||||
hasCollectionVar(key) {
|
||||
return Object.hasOwn(this.collectionVariables, key);
|
||||
}
|
||||
|
||||
deleteCollectionVar(key) {
|
||||
delete this.collectionVariables[key];
|
||||
}
|
||||
// TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// deleteCollectionVar(key) {
|
||||
// delete this.collectionVariables[key];
|
||||
// }
|
||||
|
||||
deleteAllCollectionVars() {
|
||||
for (let key in this.collectionVariables) {
|
||||
if (this.collectionVariables.hasOwnProperty(key)) {
|
||||
delete this.collectionVariables[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// deleteAllCollectionVars() {
|
||||
// for (let key in this.collectionVariables) {
|
||||
// if (this.collectionVariables.hasOwnProperty(key)) {
|
||||
// delete this.collectionVariables[key];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
getAllCollectionVars() {
|
||||
return Object.assign({}, this.collectionVariables);
|
||||
}
|
||||
// TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// getAllCollectionVars() {
|
||||
// return Object.assign({}, this.collectionVariables);
|
||||
// }
|
||||
|
||||
getFolderVar(key) {
|
||||
return this.interpolate(this.folderVariables[key]);
|
||||
|
||||
@@ -101,11 +101,13 @@ const addBruShimToContext = (vm, bru) => {
|
||||
vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
|
||||
setGlobalEnvVar.dispose();
|
||||
|
||||
let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
|
||||
bru.deleteGlobalEnvVar(vm.dump(key));
|
||||
});
|
||||
vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
|
||||
deleteGlobalEnvVar.dispose();
|
||||
// TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
|
||||
// bru.deleteGlobalEnvVar(vm.dump(key));
|
||||
// });
|
||||
// vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
|
||||
// deleteGlobalEnvVar.dispose();
|
||||
|
||||
let getAllGlobalEnvVars = vm.newFunction('getAllGlobalEnvVars', function () {
|
||||
return marshallToVm(bru.getAllGlobalEnvVars(), vm);
|
||||
@@ -113,11 +115,13 @@ const addBruShimToContext = (vm, bru) => {
|
||||
vm.setProp(bruObject, 'getAllGlobalEnvVars', getAllGlobalEnvVars);
|
||||
getAllGlobalEnvVars.dispose();
|
||||
|
||||
let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
|
||||
bru.deleteAllGlobalEnvVars();
|
||||
});
|
||||
vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
|
||||
deleteAllGlobalEnvVars.dispose();
|
||||
// TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
|
||||
// bru.deleteAllGlobalEnvVars();
|
||||
// });
|
||||
// vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
|
||||
// deleteAllGlobalEnvVars.dispose();
|
||||
|
||||
let hasVar = vm.newFunction('hasVar', function (key) {
|
||||
return marshallToVm(bru.hasVar(vm.dump(key)), vm);
|
||||
@@ -209,11 +213,13 @@ const addBruShimToContext = (vm, bru) => {
|
||||
vm.setProp(bruObject, 'getCollectionVar', getCollectionVar);
|
||||
getCollectionVar.dispose();
|
||||
|
||||
let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
|
||||
bru.setCollectionVar(vm.dump(key), vm.dump(value));
|
||||
});
|
||||
vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
|
||||
setCollectionVar.dispose();
|
||||
// TODO: setCollectionVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
|
||||
// bru.setCollectionVar(vm.dump(key), vm.dump(value));
|
||||
// });
|
||||
// vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
|
||||
// setCollectionVar.dispose();
|
||||
|
||||
let hasCollectionVar = vm.newFunction('hasCollectionVar', function (key) {
|
||||
return marshallToVm(bru.hasCollectionVar(vm.dump(key)), vm);
|
||||
@@ -221,23 +227,29 @@ const addBruShimToContext = (vm, bru) => {
|
||||
vm.setProp(bruObject, 'hasCollectionVar', hasCollectionVar);
|
||||
hasCollectionVar.dispose();
|
||||
|
||||
let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
|
||||
bru.deleteCollectionVar(vm.dump(key));
|
||||
});
|
||||
vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
|
||||
deleteCollectionVar.dispose();
|
||||
// TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
|
||||
// bru.deleteCollectionVar(vm.dump(key));
|
||||
// });
|
||||
// vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
|
||||
// deleteCollectionVar.dispose();
|
||||
|
||||
let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
|
||||
bru.deleteAllCollectionVars();
|
||||
});
|
||||
vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
|
||||
deleteAllCollectionVars.dispose();
|
||||
// TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
|
||||
// bru.deleteAllCollectionVars();
|
||||
// });
|
||||
// vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
|
||||
// deleteAllCollectionVars.dispose();
|
||||
|
||||
let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
|
||||
return marshallToVm(bru.getAllCollectionVars(), vm);
|
||||
});
|
||||
vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
|
||||
getAllCollectionVars.dispose();
|
||||
// TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
|
||||
// Re-enable once the UI sync issue is resolved.
|
||||
// let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
|
||||
// return marshallToVm(bru.getAllCollectionVars(), vm);
|
||||
// });
|
||||
// vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
|
||||
// getAllCollectionVars.dispose();
|
||||
|
||||
let getTestResults = vm.newFunction('getTestResults', () => {
|
||||
const promise = vm.newPromise();
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
|
||||
|
||||
export * as scripting from './scripting';
|
||||
|
||||
|
||||
374
packages/bruno-requests/src/utils/agent-cache.spec.ts
Normal file
374
packages/bruno-requests/src/utils/agent-cache.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './agent-cache';
|
||||
|
||||
describe('Agent Cache', () => {
|
||||
beforeEach(() => {
|
||||
clearAgentCache();
|
||||
});
|
||||
|
||||
describe('getOrCreateHttpsAgent', () => {
|
||||
it('creates a new agent when cache is empty', () => {
|
||||
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
|
||||
|
||||
expect(agent).toBeInstanceOf(https.Agent);
|
||||
expect(getAgentCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('returns cached agent for identical options', () => {
|
||||
const options = { rejectUnauthorized: true, keepAlive: true };
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
|
||||
|
||||
expect(agent1).toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('creates separate agents for different rejectUnauthorized values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different CA certificates', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-a' } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-b' } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different cert values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-a') } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { cert: Buffer.from('cert-b') } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different key values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-a') } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { key: Buffer.from('key-b') } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different pfx values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-a') } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { pfx: Buffer.from('pfx-b') } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different passphrase values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-a' } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { passphrase: 'pass-b' } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different proxy URIs', () => {
|
||||
const options = { rejectUnauthorized: true };
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy1:8080' });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, proxyUri: 'http://proxy2:8080' });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different agent classes', () => {
|
||||
const options = { keepAlive: true };
|
||||
|
||||
const httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options });
|
||||
const httpAgent = getOrCreateHttpsAgent({ AgentClass: http.Agent, options });
|
||||
|
||||
expect(httpsAgent).not.toBe(httpAgent);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different keepAlive values', () => {
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: true } });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { keepAlive: false } });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates separate agents for different hostnames', () => {
|
||||
const options = { rejectUnauthorized: true };
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'auth.example.com' });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
|
||||
it('returns cached agent for the same hostname', () => {
|
||||
const options = { rejectUnauthorized: true };
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
|
||||
|
||||
expect(agent1).toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('creates separate agents for null hostname vs explicit hostname', () => {
|
||||
const options = { rejectUnauthorized: true };
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: null });
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options, hostname: 'api.example.com' });
|
||||
|
||||
expect(agent1).not.toBe(agent2);
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeline support', () => {
|
||||
it('does not add timeline when none is provided', () => {
|
||||
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {} }) as any;
|
||||
|
||||
expect(agent.timeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses provided timeline array', () => {
|
||||
const timeline: any[] = [];
|
||||
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline }) as any;
|
||||
|
||||
expect(agent.timeline).toBe(timeline);
|
||||
});
|
||||
|
||||
it('updates timeline reference on cached agents', () => {
|
||||
const timeline1: any[] = [];
|
||||
const timeline2: any[] = [];
|
||||
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
|
||||
expect(agent1.timeline).toBe(timeline1);
|
||||
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
|
||||
expect(agent1).toBe(agent2);
|
||||
expect(agent2.timeline).toBe(timeline2);
|
||||
});
|
||||
|
||||
it('logs when reusing a cached HTTPS agent', () => {
|
||||
const timeline1: any[] = [];
|
||||
const timeline2: any[] = [];
|
||||
|
||||
// First call creates new agent - no reuse message
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 });
|
||||
expect(timeline1.some((e) => e.message.includes('Reusing cached https agent'))).toBe(false);
|
||||
|
||||
// Second call reuses cached agent - should log reuse message with SSL session reuse
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 });
|
||||
expect(timeline2.some((e) => e.message.includes('Reusing cached https agent'))).toBe(true);
|
||||
});
|
||||
|
||||
it('logs when reusing a cached HTTP agent', () => {
|
||||
const timeline1: any[] = [];
|
||||
const timeline2: any[] = [];
|
||||
|
||||
// First call creates new agent - no reuse message
|
||||
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline1 });
|
||||
expect(timeline1.some((e) => e.message.includes('Reusing cached http agent'))).toBe(false);
|
||||
|
||||
// Second call reuses cached agent - should log reuse message with connection reuse
|
||||
getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline2 });
|
||||
expect(timeline2.some((e) => e.message.includes('Reusing cached http agent'))).toBe(true);
|
||||
});
|
||||
|
||||
it('logs SSL validation status on agent creation', () => {
|
||||
const timeline: any[] = [];
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true }, timeline });
|
||||
|
||||
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
|
||||
expect(sslEntry).toBeDefined();
|
||||
expect(sslEntry.message).toContain('enabled');
|
||||
});
|
||||
|
||||
it('logs SSL validation disabled when rejectUnauthorized is false', () => {
|
||||
const timeline: any[] = [];
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false }, timeline });
|
||||
|
||||
const sslEntry = timeline.find((e) => e.message.includes('SSL validation'));
|
||||
expect(sslEntry).toBeDefined();
|
||||
expect(sslEntry.message).toContain('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAgentCache', () => {
|
||||
it('removes all cached agents', () => {
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: true } });
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { rejectUnauthorized: false } });
|
||||
expect(getAgentCacheSize()).toBe(2);
|
||||
|
||||
clearAgentCache();
|
||||
expect(getAgentCacheSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('destroys all agents when clearing cache', () => {
|
||||
const destroyMocks: jest.Mock[] = [];
|
||||
|
||||
// Create several agents and attach mock destroy functions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } }) as any;
|
||||
const mock = jest.fn();
|
||||
agent.destroy = mock;
|
||||
destroyMocks.push(mock);
|
||||
}
|
||||
|
||||
expect(getAgentCacheSize()).toBe(5);
|
||||
|
||||
clearAgentCache();
|
||||
|
||||
expect(getAgentCacheSize()).toBe(0);
|
||||
// All agents should have been destroyed
|
||||
destroyMocks.forEach((mock) => {
|
||||
expect(mock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LRU eviction', () => {
|
||||
it('maintains cache size under limit', () => {
|
||||
// Create many agents with different options
|
||||
for (let i = 0; i < 150; i++) {
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
|
||||
}
|
||||
|
||||
// Cache should be capped at MAX_AGENT_CACHE_SIZE (100)
|
||||
expect(getAgentCacheSize()).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('destroys evicted agents to prevent memory leaks', () => {
|
||||
// Create first agent and attach a mock destroy function
|
||||
const firstAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: 'cert-to-evict' } }) as any;
|
||||
const destroyMock = jest.fn();
|
||||
firstAgent.destroy = destroyMock;
|
||||
|
||||
// Fill cache to trigger eviction (100 more agents will evict the first one)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
getOrCreateHttpsAgent({ AgentClass: https.Agent, options: { ca: `cert-${i}` } });
|
||||
}
|
||||
|
||||
// First agent should have been evicted and destroyed
|
||||
expect(destroyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent requests timeline isolation', () => {
|
||||
it('isolates timeline events for concurrent requests using the same cached agent', () => {
|
||||
const timeline1: any[] = [];
|
||||
const timeline2: any[] = [];
|
||||
|
||||
// Get the same agent twice with different timelines (simulating concurrent requests)
|
||||
const agent1 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
|
||||
const agent2 = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline2 }) as any;
|
||||
|
||||
// Both should return the same cached agent
|
||||
expect(agent1).toBe(agent2);
|
||||
|
||||
// Create mock sockets to simulate concurrent connections
|
||||
const mockSocket1 = new EventEmitter() as any;
|
||||
mockSocket1.remoteAddress = '1.2.3.4';
|
||||
mockSocket1.remotePort = 443;
|
||||
mockSocket1.getProtocol = () => 'TLSv1.3';
|
||||
mockSocket1.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
|
||||
mockSocket1.alpnProtocol = 'h2';
|
||||
mockSocket1.getPeerCertificate = () => ({
|
||||
subject: { CN: 'example.com' },
|
||||
valid_from: 'Jan 1 00:00:00 2024 GMT',
|
||||
valid_to: 'Jan 1 00:00:00 2025 GMT'
|
||||
});
|
||||
mockSocket1.authorized = true;
|
||||
|
||||
const mockSocket2 = new EventEmitter() as any;
|
||||
mockSocket2.remoteAddress = '5.6.7.8';
|
||||
mockSocket2.remotePort = 443;
|
||||
mockSocket2.getProtocol = () => 'TLSv1.3';
|
||||
mockSocket2.getCipher = () => ({ name: 'AES-256-GCM', version: 'TLSv1.3' });
|
||||
mockSocket2.alpnProtocol = 'http/1.1';
|
||||
mockSocket2.getPeerCertificate = () => ({
|
||||
subject: { CN: 'other.com' },
|
||||
valid_from: 'Jan 1 00:00:00 2024 GMT',
|
||||
valid_to: 'Jan 1 00:00:00 2025 GMT'
|
||||
});
|
||||
mockSocket2.authorized = true;
|
||||
|
||||
// Mock createConnection to return our mock sockets
|
||||
const originalCreateConnection = Object.getPrototypeOf(Object.getPrototypeOf(agent1)).createConnection;
|
||||
let callCount = 0;
|
||||
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent1)), 'createConnection').mockImplementation(function (this: any, options: any, callback: any) {
|
||||
callCount++;
|
||||
return callCount === 1 ? mockSocket1 : mockSocket2;
|
||||
});
|
||||
|
||||
// Simulate request 1 starting - this captures timeline1 in the closure
|
||||
agent1.timeline = timeline1;
|
||||
const socket1 = agent1.createConnection({ host: 'example.com', port: 443 }, () => {});
|
||||
|
||||
// Before request 1's events fire, request 2 starts and updates agent.timeline
|
||||
// This simulates the race condition
|
||||
agent1.timeline = timeline2;
|
||||
const socket2 = agent1.createConnection({ host: 'other.com', port: 443 }, () => {});
|
||||
|
||||
// Now fire events for both sockets - they should go to their respective timelines
|
||||
mockSocket1.emit('connect');
|
||||
mockSocket1.emit('secureConnect');
|
||||
|
||||
mockSocket2.emit('connect');
|
||||
mockSocket2.emit('secureConnect');
|
||||
|
||||
// Verify timeline1 only contains events for request 1 (example.com)
|
||||
const timeline1Messages = timeline1.map((e) => e.message);
|
||||
expect(timeline1Messages.some((m) => m.includes('example.com'))).toBe(true);
|
||||
expect(timeline1Messages.some((m) => m.includes('other.com'))).toBe(false);
|
||||
|
||||
// Verify timeline2 only contains events for request 2 (other.com)
|
||||
const timeline2Messages = timeline2.map((e) => e.message);
|
||||
expect(timeline2Messages.some((m) => m.includes('other.com'))).toBe(true);
|
||||
expect(timeline2Messages.some((m) => m.includes('example.com'))).toBe(false);
|
||||
|
||||
// Restore the original implementation
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('logs events to captured timeline even after agent.timeline is reassigned', () => {
|
||||
const timeline1: any[] = [];
|
||||
const timeline2: any[] = [];
|
||||
|
||||
const agent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: {}, timeline: timeline1 }) as any;
|
||||
|
||||
// Create a mock socket
|
||||
const mockSocket = new EventEmitter() as any;
|
||||
mockSocket.remoteAddress = '1.2.3.4';
|
||||
mockSocket.remotePort = 443;
|
||||
|
||||
// Mock createConnection
|
||||
jest.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(agent)), 'createConnection').mockImplementation(() => mockSocket);
|
||||
|
||||
// Start creating connection - this captures timeline1
|
||||
const socket = agent.createConnection({ host: 'test.com', port: 443 }, () => {});
|
||||
|
||||
// Reassign agent.timeline (simulating another request coming in)
|
||||
agent.timeline = timeline2;
|
||||
|
||||
// Fire the connect event - this should still go to timeline1 (captured reference)
|
||||
mockSocket.emit('connect');
|
||||
|
||||
// Verify event went to timeline1, not timeline2
|
||||
expect(timeline1.some((e) => e.message.includes('Connected to test.com'))).toBe(true);
|
||||
expect(timeline2.some((e) => e.message.includes('Connected to test.com'))).toBe(false);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
393
packages/bruno-requests/src/utils/agent-cache.ts
Normal file
393
packages/bruno-requests/src/utils/agent-cache.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import crypto from 'node:crypto';
|
||||
import tls from 'node:tls';
|
||||
import type { Agent as HttpAgent } from 'node:http';
|
||||
import type { Agent as HttpsAgent } from 'node:https';
|
||||
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
|
||||
|
||||
/**
|
||||
* Agent cache for SSL session reuse.
|
||||
* Agents are cached by their configuration to enable TLS session resumption,
|
||||
* which significantly reduces SSL handshake time for repeated requests.
|
||||
*/
|
||||
const agentCache = new Map<string, HttpAgent | HttpsAgent>();
|
||||
|
||||
/**
|
||||
* Maximum number of agents to cache.
|
||||
* 100 provides a good balance between memory usage and SSL session reuse.
|
||||
* Each agent maintains persistent connections, so higher values increase memory.
|
||||
* Lower values may reduce SSL session hits for users with many different TLS configs.
|
||||
*/
|
||||
const MAX_AGENT_CACHE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Cache for timeline-wrapped HTTPS agent classes.
|
||||
* Prevents creating new class definitions on every call.
|
||||
*/
|
||||
const timelineClassCache = new WeakMap<any, AgentClass>();
|
||||
|
||||
/**
|
||||
* Cache for timeline-wrapped HTTP agent classes.
|
||||
* Prevents creating new class definitions on every call.
|
||||
*/
|
||||
const timelineHttpClassCache = new WeakMap<any, HttpAgentClass>();
|
||||
|
||||
/**
|
||||
* Map to assign unique IDs to agent classes.
|
||||
* Used for cache key generation since different classes may have the same name.
|
||||
*/
|
||||
const agentClassIdMap = new WeakMap<any, number>();
|
||||
let agentClassIdCounter = 0;
|
||||
|
||||
function getAgentClassId(AgentClass: any): number {
|
||||
if (agentClassIdMap.has(AgentClass)) {
|
||||
return agentClassIdMap.get(AgentClass)!;
|
||||
}
|
||||
const id = ++agentClassIdCounter;
|
||||
agentClassIdMap.set(AgentClass, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a value using SHA-256 and return a truncated hex string.
|
||||
* Truncated to 16 chars for compact cache keys while maintaining uniqueness.
|
||||
*/
|
||||
function hashValue(value: string | Buffer | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const data = Buffer.isBuffer(value) ? value : String(value);
|
||||
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for secure contexts created from CA options.
|
||||
* Keyed by the hash of the CA value to avoid creating duplicate contexts.
|
||||
*/
|
||||
const secureContextCache = new Map<string, tls.SecureContext>();
|
||||
|
||||
/**
|
||||
* Build a TLS secure context that adds custom CAs on top of the OpenSSL defaults.
|
||||
*
|
||||
* When Node.js receives an explicit `ca` option in tls.connect() or https.Agent,
|
||||
* it replaces the default CA store entirely. This means CAs that are only in the
|
||||
* OpenSSL default trust store (e.g. /etc/ssl/cert.pem) but not in
|
||||
* tls.rootCertificates or tls.getCACertificates('system') are lost.
|
||||
*
|
||||
* This function creates a secureContext starting from the OpenSSL defaults
|
||||
* and adds custom CAs on top via addCACert(), which appends rather than replaces.
|
||||
*/
|
||||
function buildSecureContext(ca: string | Buffer | (string | Buffer)[]): tls.SecureContext {
|
||||
const caHash = hashCaValue(ca);
|
||||
if (caHash && secureContextCache.has(caHash)) {
|
||||
return secureContextCache.get(caHash)!;
|
||||
}
|
||||
|
||||
const ctx = tls.createSecureContext();
|
||||
const caList = Array.isArray(ca) ? ca : [ca];
|
||||
for (const cert of caList) {
|
||||
if (cert) {
|
||||
ctx.context.addCACert(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (caHash) {
|
||||
secureContextCache.set(caHash, ctx);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert agent options to use a secureContext instead of raw `ca`.
|
||||
* This ensures custom CAs are added on top of the OpenSSL defaults
|
||||
* rather than replacing the default CA store.
|
||||
*
|
||||
* When client certificates (pfx/cert/key) are also present, they are loaded
|
||||
* into the secure context so they aren't silently ignored by Node.js
|
||||
* (Node.js skips pfx/cert/key/ca when a secureContext is provided).
|
||||
*/
|
||||
function applySecureContext<T extends AgentOptions | HttpAgentOptions>(options: T): T {
|
||||
if ('ca' in options && (options as AgentOptions).ca) {
|
||||
const { ca, ...rest } = options as AgentOptions;
|
||||
|
||||
// When client certs are present alongside CA, build a combined context
|
||||
// that includes both. This context can't be CA-cached since it's unique
|
||||
// per client cert + CA combination.
|
||||
const hasClientCert = rest.pfx || rest.cert || rest.key;
|
||||
if (hasClientCert) {
|
||||
const ctxOptions: Record<string, any> = {};
|
||||
if (rest.pfx) ctxOptions.pfx = rest.pfx;
|
||||
if (rest.cert) ctxOptions.cert = rest.cert;
|
||||
if (rest.key) ctxOptions.key = rest.key;
|
||||
if (rest.passphrase) ctxOptions.passphrase = rest.passphrase;
|
||||
|
||||
const ctx = tls.createSecureContext(ctxOptions);
|
||||
const caList = Array.isArray(ca) ? ca : [ca!];
|
||||
for (const caCert of caList) {
|
||||
if (caCert) ctx.context.addCACert(caCert);
|
||||
}
|
||||
|
||||
const { pfx: _pfx, cert: _cert, key: _key, passphrase: _pass, ...cleanRest } = rest;
|
||||
return { ...cleanRest, secureContext: ctx } as unknown as T;
|
||||
}
|
||||
|
||||
// CA-only case: use cached secure context
|
||||
return { ...rest, secureContext: buildSecureContext(ca!) } as unknown as T;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a CA value which can be a single value or an array of certificates.
|
||||
* Node.js TLS options allow ca to be string | Buffer | (string | Buffer)[].
|
||||
*/
|
||||
function hashCaValue(value: string | Buffer | (string | Buffer)[] | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (Array.isArray(value)) {
|
||||
// Concatenate all values with separator and hash together
|
||||
const combined = value.map((v) => (Buffer.isBuffer(v) ? v.toString('base64') : String(v))).join('|');
|
||||
return crypto.createHash('sha256').update(combined).digest('hex').slice(0, 16);
|
||||
}
|
||||
const data = Buffer.isBuffer(value) ? value : String(value);
|
||||
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from HTTPS agent options.
|
||||
* Uses a hash of the serialized options to create a compact key.
|
||||
*/
|
||||
function getAgentCacheKey(agentClassId: number, options: AgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
|
||||
// Extract the TLS-relevant options for the cache key
|
||||
const keyData = {
|
||||
agentClassId,
|
||||
hostname: proxyUri?.length ? null : hostname,
|
||||
proxyUri,
|
||||
keepAlive: options.keepAlive,
|
||||
rejectUnauthorized: options.rejectUnauthorized,
|
||||
// Hash certificates and passphrase instead of including full content
|
||||
ca: hashCaValue(options.ca),
|
||||
cert: hashValue(options.cert),
|
||||
key: hashValue(options.key),
|
||||
pfx: hashValue(options.pfx),
|
||||
passphrase: hashValue(options.passphrase),
|
||||
minVersion: options.minVersion,
|
||||
secureProtocol: options.secureProtocol
|
||||
};
|
||||
return JSON.stringify(keyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from HTTP agent options.
|
||||
* Simpler than HTTPS since no TLS options are involved.
|
||||
*/
|
||||
function getHttpAgentCacheKey(agentClassId: number, options: HttpAgentOptions, proxyUri: string | null = null, hostname: string | null = null): string {
|
||||
const keyData = {
|
||||
agentClassId,
|
||||
hostname: proxyUri?.length ? null : hostname,
|
||||
proxyUri,
|
||||
keepAlive: options.keepAlive
|
||||
};
|
||||
return JSON.stringify(keyData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached timeline-wrapped HTTPS agent class.
|
||||
* Creates the wrapped class once and caches it for reuse.
|
||||
*/
|
||||
function getTimelineAgentClass(BaseAgentClass: any): AgentClass {
|
||||
if (timelineClassCache.has(BaseAgentClass)) {
|
||||
return timelineClassCache.get(BaseAgentClass)!;
|
||||
}
|
||||
const wrappedClass = createTimelineAgentClass(BaseAgentClass);
|
||||
timelineClassCache.set(BaseAgentClass, wrappedClass);
|
||||
return wrappedClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached timeline-wrapped HTTP agent class.
|
||||
* Creates the wrapped class once and caches it for reuse.
|
||||
*/
|
||||
function getTimelineHttpAgentClass(BaseAgentClass: any): HttpAgentClass {
|
||||
if (timelineHttpClassCache.has(BaseAgentClass)) {
|
||||
return timelineHttpClassCache.get(BaseAgentClass)!;
|
||||
}
|
||||
const wrappedClass = createTimelineHttpAgentClass(BaseAgentClass);
|
||||
timelineHttpClassCache.set(BaseAgentClass, wrappedClass);
|
||||
return wrappedClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for cache key generation functions.
|
||||
*/
|
||||
type CacheKeyFn<T> = (classId: number, options: T, proxyUri: string | null, hostname: string | null) => string;
|
||||
|
||||
/**
|
||||
* Type for timeline class wrapper functions.
|
||||
*/
|
||||
type TimelineClassFn = (base: any) => AgentClass | HttpAgentClass;
|
||||
|
||||
/**
|
||||
* Internal helper for agent caching with LRU eviction.
|
||||
* Shared logic for both HTTP and HTTPS agents.
|
||||
*/
|
||||
function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
|
||||
BaseAgentClass: any,
|
||||
options: TOptions,
|
||||
proxyUri: string | null,
|
||||
timeline: TimelineEntry[] | null,
|
||||
getCacheKey: CacheKeyFn<TOptions>,
|
||||
getTimelineClass: TimelineClassFn,
|
||||
cacheHitMessage: string,
|
||||
disableCache: boolean = false,
|
||||
hostname: string | null = null
|
||||
): HttpAgent | HttpsAgent {
|
||||
const agentClassId = getAgentClassId(BaseAgentClass);
|
||||
const cacheKey = getCacheKey(agentClassId, options, proxyUri, hostname);
|
||||
|
||||
if (!disableCache && agentCache.has(cacheKey)) {
|
||||
// Move to end for LRU (delete and re-add)
|
||||
const agent = agentCache.get(cacheKey)!;
|
||||
agentCache.delete(cacheKey);
|
||||
agentCache.set(cacheKey, agent);
|
||||
|
||||
// Update timeline reference for new request
|
||||
// The cached agent was created with a previous timeline,
|
||||
// but we need events to go to the current request's timeline
|
||||
if (timeline && 'timeline' in agent) {
|
||||
(agent as any).timeline = timeline;
|
||||
}
|
||||
|
||||
// Log that we're reusing a cached agent
|
||||
if (timeline) {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'info',
|
||||
message: cacheHitMessage
|
||||
});
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
|
||||
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
|
||||
const resolvedOptions = applySecureContext(options);
|
||||
|
||||
let agent: HttpAgent | HttpsAgent;
|
||||
if (timeline) {
|
||||
// Timeline-wrapped classes handle proxy internally via options.proxy
|
||||
const agentOptions = proxyUri ? { ...resolvedOptions, proxy: proxyUri } : resolvedOptions;
|
||||
agent = new AgentClass(agentOptions, timeline);
|
||||
} else if (proxyUri) {
|
||||
// Proxy agent classes expect (proxyUri, options) constructor signature
|
||||
agent = new BaseAgentClass(proxyUri, resolvedOptions);
|
||||
} else {
|
||||
agent = new BaseAgentClass(resolvedOptions);
|
||||
}
|
||||
|
||||
if (!disableCache) {
|
||||
// Evict oldest entry if cache is full (LRU eviction)
|
||||
if (agentCache.size >= MAX_AGENT_CACHE_SIZE) {
|
||||
const firstKey = agentCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
const evictedAgent = agentCache.get(firstKey);
|
||||
agentCache.delete(firstKey);
|
||||
// Destroy the agent to release its sockets and prevent memory leaks
|
||||
if (evictedAgent && typeof (evictedAgent as any).destroy === 'function') {
|
||||
(evictedAgent as any).destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentCache.set(cacheKey, agent);
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached HTTPS agent.
|
||||
* Reuses existing agents to enable SSL session caching.
|
||||
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
|
||||
* Automatically wraps the agent class with timeline logging support.
|
||||
*/
|
||||
function getOrCreateHttpsAgent({
|
||||
AgentClass,
|
||||
options,
|
||||
proxyUri = null,
|
||||
timeline = null,
|
||||
disableCache = false,
|
||||
hostname = null
|
||||
}: {
|
||||
AgentClass: any;
|
||||
options: AgentOptions;
|
||||
proxyUri?: string | null;
|
||||
timeline?: TimelineEntry[] | null;
|
||||
disableCache?: boolean;
|
||||
hostname?: string | null;
|
||||
}): HttpAgent | HttpsAgent {
|
||||
return getOrCreateAgentInternal(
|
||||
AgentClass,
|
||||
options,
|
||||
proxyUri,
|
||||
timeline,
|
||||
getAgentCacheKey,
|
||||
getTimelineAgentClass,
|
||||
'Reusing cached https agent',
|
||||
disableCache,
|
||||
hostname
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached HTTP agent.
|
||||
* Reuses existing agents to enable connection reuse.
|
||||
* Uses LRU-style eviction when cache exceeds MAX_AGENT_CACHE_SIZE.
|
||||
* Automatically wraps the agent class with timeline logging support.
|
||||
*/
|
||||
function getOrCreateHttpAgent({
|
||||
AgentClass,
|
||||
options,
|
||||
proxyUri = null,
|
||||
timeline = null,
|
||||
disableCache = false,
|
||||
hostname = null
|
||||
}: {
|
||||
AgentClass: any;
|
||||
options: HttpAgentOptions;
|
||||
proxyUri?: string | null;
|
||||
timeline?: TimelineEntry[] | null;
|
||||
disableCache?: boolean;
|
||||
hostname?: string | null;
|
||||
}): HttpAgent {
|
||||
return getOrCreateAgentInternal(
|
||||
AgentClass,
|
||||
options,
|
||||
proxyUri,
|
||||
timeline,
|
||||
getHttpAgentCacheKey,
|
||||
getTimelineHttpAgentClass,
|
||||
'Reusing cached http agent',
|
||||
disableCache,
|
||||
hostname
|
||||
) as HttpAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the agent cache. Useful for testing or when SSL configuration changes.
|
||||
* Destroys all cached agents to properly release their sockets.
|
||||
*/
|
||||
function clearAgentCache(): void {
|
||||
for (const agent of agentCache.values()) {
|
||||
if (agent && typeof (agent as any).destroy === 'function') {
|
||||
(agent as any).destroy();
|
||||
}
|
||||
}
|
||||
agentCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the agent cache.
|
||||
*/
|
||||
function getAgentCacheSize(): number {
|
||||
return agentCache.size;
|
||||
}
|
||||
|
||||
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize };
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import type { Agent as HttpAgent } from 'node:http';
|
||||
import type { Agent as HttpsAgent } from 'node:https';
|
||||
@@ -10,6 +11,8 @@ import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { isEmpty, get, isUndefined, isNull } from 'lodash';
|
||||
import { getCACertificates } from './ca-cert';
|
||||
import { transformProxyConfig } from './proxy-util';
|
||||
import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
|
||||
import type { TimelineEntry } from './timeline-agent';
|
||||
|
||||
const DEFAULT_PORTS: Record<string, number> = {
|
||||
ftp: 21,
|
||||
@@ -93,6 +96,7 @@ type ConfigOptions = {
|
||||
shouldUseCustomCaCertificate: boolean;
|
||||
customCaCertificateFilePath?: string;
|
||||
shouldKeepDefaultCaCertificates: boolean;
|
||||
cacheSslSession?: boolean;
|
||||
};
|
||||
|
||||
type GetCertsAndProxyConfigParams = {
|
||||
@@ -120,6 +124,8 @@ type CreateAgentsParams = {
|
||||
certsConfig: CertsConfig;
|
||||
httpsAgentRequestFields: HttpsAgentRequestFields;
|
||||
systemProxyConfig?: SystemProxyConfig;
|
||||
timeline?: TimelineEntry[];
|
||||
disableCache?: boolean;
|
||||
};
|
||||
|
||||
type GetHttpHttpsAgentsParams = {
|
||||
@@ -132,6 +138,7 @@ type GetHttpHttpsAgentsParams = {
|
||||
collectionLevelProxy?: ProxyConfig;
|
||||
appLevelProxyConfig?: Record<string, any>;
|
||||
systemProxyConfig?: SystemProxyConfig;
|
||||
timeline?: TimelineEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -188,9 +195,21 @@ const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent to get around a bug that ignores options
|
||||
* such as ca and rejectUnauthorized when upgrading the proxied socket to TLS:
|
||||
* https://github.com/TooTallNate/proxy-agents/issues/194
|
||||
* Options that should be forwarded from the constructor to the target TLS upgrade.
|
||||
* The upstream HttpsProxyAgent (https://github.com/TooTallNate/proxy-agents/issues/194)
|
||||
* ignores constructor options when upgrading the tunneled socket to TLS for the
|
||||
* target server. This list covers client certificates, verification, and secure context.
|
||||
*/
|
||||
const TARGET_TLS_OPTIONS = ['cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'secureContext'] as const;
|
||||
|
||||
/**
|
||||
* Patched version of HttpsProxyAgent that correctly handles TLS options for
|
||||
* both the proxy connection and the target server connection.
|
||||
*
|
||||
* This patch forwards client certificate options, rejectUnauthorized, and
|
||||
* secureContext to the target TLS upgrade. The agent-cache layer converts raw
|
||||
* `ca` to a secureContext (via addCACert) before construction, so custom CAs
|
||||
* are added on top of the OpenSSL defaults rather than replacing them.
|
||||
*/
|
||||
class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
|
||||
private constructorOpts: any;
|
||||
@@ -201,8 +220,18 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent<any> {
|
||||
}
|
||||
|
||||
async connect(req: any, opts: any) {
|
||||
const combinedOpts = { ...this.constructorOpts, ...opts };
|
||||
return super.connect(req, combinedOpts);
|
||||
const targetOpts = { ...opts };
|
||||
|
||||
// Forward TLS options to the target TLS upgrade
|
||||
if (this.constructorOpts) {
|
||||
for (const key of TARGET_TLS_OPTIONS) {
|
||||
if (key in this.constructorOpts) {
|
||||
targetOpts[key] = this.constructorOpts[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.connect(req, targetOpts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,13 +365,24 @@ const getCertsAndProxyConfig = ({
|
||||
return { proxyMode, proxyConfig, certsConfig };
|
||||
};
|
||||
|
||||
function extractHostname(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
systemProxyConfig,
|
||||
certsConfig,
|
||||
httpsAgentRequestFields
|
||||
httpsAgentRequestFields,
|
||||
timeline,
|
||||
disableCache = true
|
||||
}: CreateAgentsParams): AgentResult {
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions: TlsOptions = {
|
||||
@@ -358,13 +398,19 @@ function createAgents({
|
||||
let httpAgent: HttpAgent | undefined;
|
||||
let httpsAgent: HttpsAgent | HttpsProxyAgent<any> | SocksProxyAgent | undefined;
|
||||
|
||||
// Determine if this is an HTTPS request
|
||||
const isHttpsRequest = requestUrl ? requestUrl.startsWith('https:') : true;
|
||||
|
||||
// Extract hostname for per-host agent caching (enables TLS session reuse per host)
|
||||
const hostname = extractHostname(requestUrl);
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (shouldProxy) {
|
||||
const proxyProtocol = get(proxyConfig, 'protocol');
|
||||
const proxyHostname = get(proxyConfig, 'hostname');
|
||||
const proxyPort = get(proxyConfig, 'port');
|
||||
const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false);
|
||||
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
|
||||
const socksEnabled = proxyProtocol && proxyProtocol.includes('socks');
|
||||
|
||||
if (!proxyProtocol || !proxyHostname) {
|
||||
@@ -381,16 +427,31 @@ function createAgents({
|
||||
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
|
||||
}
|
||||
|
||||
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
|
||||
// (e.g., ca certs) even for plain HTTP requests
|
||||
const isHttpsProxy = proxyProtocol === 'https';
|
||||
const httpProxyAgentOptions = isHttpsProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
||||
|
||||
// Only set the agent needed for the request protocol
|
||||
if (socksEnabled) {
|
||||
httpAgent = new SocksProxyAgent(proxyUri);
|
||||
httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any);
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
} else {
|
||||
httpAgent = new HttpProxyAgent(proxyUri);
|
||||
httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions);
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If proxy should not be used, set default HTTPS agent
|
||||
httpsAgent = new https.Agent(tlsOptions as any);
|
||||
// If proxy should not be used, only set HTTPS agent for HTTPS requests
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
}
|
||||
// HTTP requests without proxy don't need a custom agent
|
||||
}
|
||||
} else if (proxyMode === 'system') {
|
||||
const http_proxy = get(systemProxyConfig, 'http_proxy');
|
||||
@@ -399,28 +460,32 @@ function createAgents({
|
||||
const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || '');
|
||||
if (shouldUseSystemProxy) {
|
||||
try {
|
||||
if (http_proxy?.length) {
|
||||
new URL(http_proxy);
|
||||
httpAgent = new HttpProxyAgent(http_proxy);
|
||||
if (http_proxy?.length && !isHttpsRequest) {
|
||||
const parsedHttpProxy = new URL(http_proxy);
|
||||
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
|
||||
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { keepAlive: true, ...tlsOptions } : { keepAlive: true };
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions as any, proxyUri: http_proxy, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system http_proxy');
|
||||
}
|
||||
try {
|
||||
if (https_proxy?.length) {
|
||||
if (https_proxy?.length && isHttpsRequest) {
|
||||
new URL(https_proxy);
|
||||
httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any);
|
||||
} else {
|
||||
httpsAgent = new https.Agent(tlsOptions as any);
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri: https_proxy, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Invalid system https_proxy');
|
||||
}
|
||||
} else {
|
||||
httpsAgent = new https.Agent(tlsOptions as any);
|
||||
}
|
||||
} else {
|
||||
httpsAgent = new https.Agent(tlsOptions as any);
|
||||
}
|
||||
|
||||
if (!httpAgent && !httpsAgent) {
|
||||
if (isHttpsRequest) {
|
||||
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
|
||||
} else {
|
||||
httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: { keepAlive: true }, timeline: timeline || null, disableCache, hostname });
|
||||
}
|
||||
}
|
||||
|
||||
return { httpAgent, httpsAgent };
|
||||
@@ -433,7 +498,8 @@ const getHttpHttpsAgents = async ({
|
||||
collectionLevelProxy,
|
||||
appLevelProxyConfig,
|
||||
systemProxyConfig,
|
||||
options
|
||||
options,
|
||||
timeline
|
||||
}: GetHttpHttpsAgentsParams): Promise<AgentResult> => {
|
||||
const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({
|
||||
requestUrl,
|
||||
@@ -460,7 +526,9 @@ const getHttpHttpsAgents = async ({
|
||||
proxyConfig,
|
||||
systemProxyConfig,
|
||||
certsConfig,
|
||||
httpsAgentRequestFields
|
||||
httpsAgentRequestFields,
|
||||
timeline,
|
||||
disableCache: !options.cacheSslSession
|
||||
});
|
||||
|
||||
return { httpAgent, httpsAgent };
|
||||
|
||||
309
packages/bruno-requests/src/utils/timeline-agent.ts
Normal file
309
packages/bruno-requests/src/utils/timeline-agent.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
type TimelineEntry = {
|
||||
timestamp: Date;
|
||||
type: 'info' | 'tls' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type CaCertificatesCount = {
|
||||
root?: number;
|
||||
system?: number;
|
||||
extra?: number;
|
||||
custom?: number;
|
||||
};
|
||||
|
||||
type AgentOptions = {
|
||||
rejectUnauthorized?: boolean;
|
||||
ca?: string | string[] | Buffer | Buffer[];
|
||||
cert?: string | Buffer;
|
||||
key?: string | Buffer;
|
||||
pfx?: string | Buffer;
|
||||
passphrase?: string;
|
||||
minVersion?: string;
|
||||
secureProtocol?: string;
|
||||
keepAlive?: boolean;
|
||||
ALPNProtocols?: string[];
|
||||
caCertificatesCount?: CaCertificatesCount;
|
||||
proxy?: string;
|
||||
secureContext?: any;
|
||||
};
|
||||
|
||||
type AgentClass = new (options: AgentOptions, timeline?: TimelineEntry[]) => https.Agent;
|
||||
type ProxyAgentClass = new (proxyUri: string, options?: AgentOptions) => https.Agent;
|
||||
|
||||
type HttpAgentOptions = {
|
||||
keepAlive?: boolean;
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
type HttpAgentClass = new (options: HttpAgentOptions, timeline?: TimelineEntry[]) => http.Agent;
|
||||
type HttpProxyAgentClass = new (proxyUri: string, options?: HttpAgentOptions) => http.Agent;
|
||||
|
||||
/**
|
||||
* Creates a timeline-aware agent class that logs TLS connection events.
|
||||
* The returned class wraps the base agent and adds timeline logging for:
|
||||
* - SSL validation status
|
||||
* - Proxy usage
|
||||
* - ALPN protocol negotiation
|
||||
* - CA certificates info
|
||||
* - DNS lookups
|
||||
* - Connection establishment
|
||||
* - TLS handshake details
|
||||
* - Server certificate info
|
||||
*/
|
||||
function createTimelineAgentClass<T extends ProxyAgentClass | typeof https.Agent>(BaseAgentClass: T): AgentClass {
|
||||
return class TimelineAgent extends (BaseAgentClass as any) {
|
||||
timeline: TimelineEntry[];
|
||||
alpnProtocols: string[];
|
||||
caProvided: boolean;
|
||||
caCertificatesCount: CaCertificatesCount;
|
||||
|
||||
/**
|
||||
* Helper method to log entries to the timeline.
|
||||
*/
|
||||
private log(type: 'info' | 'tls' | 'error', message: string): void {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: AgentOptions, timeline?: TimelineEntry[]) {
|
||||
const caCertificatesCount = options.caCertificatesCount || {};
|
||||
const optionsCopy = { ...options };
|
||||
delete optionsCopy.caCertificatesCount;
|
||||
|
||||
// For proxy agents, the first argument is the proxy URI and the second is options
|
||||
if (optionsCopy?.proxy) {
|
||||
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions = {
|
||||
...agentOptions,
|
||||
rejectUnauthorized: agentOptions.rejectUnauthorized ?? true
|
||||
};
|
||||
super(proxyUri, tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = tlsOptions.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!(tlsOptions.ca || tlsOptions.secureContext);
|
||||
|
||||
// Log TLS verification status and proxy details
|
||||
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
|
||||
this.log('info', `Using proxy: ${proxyUri}`);
|
||||
} else {
|
||||
// This is a regular HTTPS agent case
|
||||
const tlsOptions = {
|
||||
...optionsCopy,
|
||||
rejectUnauthorized: optionsCopy.rejectUnauthorized ?? true
|
||||
};
|
||||
super(tlsOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
this.alpnProtocols = optionsCopy.ALPNProtocols || ['h2', 'http/1.1'];
|
||||
this.caProvided = !!(optionsCopy.ca || optionsCopy.secureContext);
|
||||
|
||||
// Log TLS verification status
|
||||
this.log('info', `SSL validation: ${tlsOptions.rejectUnauthorized ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
this.caCertificatesCount = caCertificatesCount;
|
||||
}
|
||||
|
||||
createConnection(options: any, callback: any) {
|
||||
const { host, port } = options;
|
||||
|
||||
// Capture the current timeline reference to avoid race conditions
|
||||
// when multiple concurrent requests reuse the same cached agent
|
||||
const timeline = this.timeline;
|
||||
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
// Log ALPN protocols offered
|
||||
if (this.alpnProtocols && this.alpnProtocols.length > 0) {
|
||||
log('tls', `ALPN: offers ${this.alpnProtocols.join(', ')}`);
|
||||
}
|
||||
|
||||
const rootCerts = this.caCertificatesCount.root || 0;
|
||||
const systemCerts = this.caCertificatesCount.system || 0;
|
||||
const extraCerts = this.caCertificatesCount.extra || 0;
|
||||
const customCerts = this.caCertificatesCount.custom || 0;
|
||||
|
||||
log('tls', `CA Certificates: ${rootCerts} root, ${systemCerts} system, ${extraCerts} extra, ${customCerts} custom`);
|
||||
|
||||
// Log "Trying host:port..."
|
||||
log('info', `Trying ${host}:${port}...`);
|
||||
|
||||
let socket: any;
|
||||
try {
|
||||
socket = super.createConnection(options, callback);
|
||||
} catch (error: any) {
|
||||
log('error', `Error creating connection: ${error.message}`);
|
||||
error.timeline = timeline;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Attach event listeners to the socket
|
||||
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
|
||||
if (err) {
|
||||
log('error', `DNS lookup error for ${host}: ${err.message}`);
|
||||
} else {
|
||||
log('info', `DNS lookup: ${host} -> ${address}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on('connect', () => {
|
||||
const address = socket.remoteAddress || host;
|
||||
const remotePort = socket.remotePort || port;
|
||||
|
||||
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
|
||||
});
|
||||
|
||||
socket?.on('secureConnect', () => {
|
||||
const protocol = socket.getProtocol?.() || 'SSL/TLS';
|
||||
const cipher = socket.getCipher?.();
|
||||
const cipherSuite = cipher ? `${cipher.name} (${cipher.version})` : 'Unknown cipher';
|
||||
|
||||
log('tls', `SSL connection using ${protocol} / ${cipherSuite}`);
|
||||
|
||||
// ALPN protocol
|
||||
const alpnProtocol = socket.alpnProtocol || 'None';
|
||||
log('tls', `ALPN: server accepted ${alpnProtocol}`);
|
||||
|
||||
// Server certificate
|
||||
const cert = socket.getPeerCertificate?.(true);
|
||||
if (cert) {
|
||||
log('tls', `Server certificate:`);
|
||||
if (cert.subject) {
|
||||
log('tls', ` subject: ${Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(', ')}`);
|
||||
}
|
||||
if (cert.valid_from) {
|
||||
log('tls', ` start date: ${cert.valid_from}`);
|
||||
}
|
||||
if (cert.valid_to) {
|
||||
log('tls', ` expire date: ${cert.valid_to}`);
|
||||
}
|
||||
if (cert.subjectaltname) {
|
||||
log('tls', ` subjectAltName: ${cert.subjectaltname}`);
|
||||
}
|
||||
if (cert.issuer) {
|
||||
log('tls', ` issuer: ${Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(', ')}`);
|
||||
}
|
||||
|
||||
// SSL certificate verification status
|
||||
if (socket.authorized !== false) {
|
||||
log('tls', `SSL certificate verify ok.`);
|
||||
} else {
|
||||
log('tls', `SSL certificate verification skipped (rejectUnauthorized: false).`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on('error', (err: Error) => {
|
||||
log('error', `Socket error: ${err.message}`);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
} as unknown as AgentClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timeline-aware HTTP agent class that logs connection events.
|
||||
* The returned class wraps the base HTTP agent and adds timeline logging for:
|
||||
* - Proxy usage (when applicable)
|
||||
* - DNS lookups
|
||||
* - Connection establishment
|
||||
* - Errors
|
||||
*
|
||||
* This is a simplified version of createTimelineAgentClass for HTTP (non-TLS) connections.
|
||||
*/
|
||||
function createTimelineHttpAgentClass<T extends HttpProxyAgentClass | typeof http.Agent>(BaseAgentClass: T): HttpAgentClass {
|
||||
return class TimelineHttpAgent extends (BaseAgentClass as any) {
|
||||
timeline: TimelineEntry[];
|
||||
|
||||
/**
|
||||
* Helper method to log entries to the timeline.
|
||||
*/
|
||||
private log(type: 'info' | 'tls' | 'error', message: string): void {
|
||||
this.timeline.push({
|
||||
timestamp: new Date(),
|
||||
type,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: HttpAgentOptions, timeline?: TimelineEntry[]) {
|
||||
const optionsCopy = { ...options };
|
||||
|
||||
// For proxy agents, the first argument is the proxy URI and the second is options
|
||||
if (optionsCopy?.proxy) {
|
||||
const { proxy: proxyUri, ...agentOptions } = optionsCopy;
|
||||
super(proxyUri, agentOptions);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
|
||||
// Log proxy details
|
||||
this.log('info', `Using proxy: ${proxyUri}`);
|
||||
} else {
|
||||
super(optionsCopy);
|
||||
this.timeline = Array.isArray(timeline) ? timeline : [];
|
||||
}
|
||||
}
|
||||
|
||||
createConnection(options: any, callback: any) {
|
||||
const { host, port } = options;
|
||||
|
||||
// Capture the current timeline reference to avoid race conditions
|
||||
// when multiple concurrent requests reuse the same cached agent
|
||||
const timeline = this.timeline;
|
||||
const log = (type: 'info' | 'tls' | 'error', message: string): void => {
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
// Log "Trying host:port..."
|
||||
log('info', `Trying ${host}:${port}...`);
|
||||
|
||||
let socket: any;
|
||||
try {
|
||||
socket = super.createConnection(options, callback);
|
||||
} catch (error: any) {
|
||||
log('error', `Error creating connection: ${error.message}`);
|
||||
error.timeline = timeline;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Attach event listeners to the socket
|
||||
socket?.on('lookup', (err: Error | null, address: string, family: number, host: string) => {
|
||||
if (err) {
|
||||
log('error', `DNS lookup error for ${host}: ${err.message}`);
|
||||
} else {
|
||||
log('info', `DNS lookup: ${host} -> ${address}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on('connect', () => {
|
||||
const address = socket.remoteAddress || host;
|
||||
const remotePort = socket.remotePort || port;
|
||||
|
||||
log('info', `Connected to ${host} (${address}) port ${remotePort}`);
|
||||
});
|
||||
|
||||
socket?.on('error', (err: Error) => {
|
||||
log('error', `Socket error: ${err.message}`);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
} as unknown as HttpAgentClass;
|
||||
}
|
||||
|
||||
export { createTimelineAgentClass, createTimelineHttpAgentClass, TimelineEntry, AgentOptions, HttpAgentOptions, CaCertificatesCount, AgentClass, HttpAgentClass, ProxyAgentClass, HttpProxyAgentClass };
|
||||
@@ -12,4 +12,4 @@ get {
|
||||
|
||||
script:pre-request {
|
||||
bru.runner.stopExecution();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ get {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because deleteAllCollectionVars does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
bru.setCollectionVar("testDelAllCollectionA", "a");
|
||||
bru.setCollectionVar("testDelAllCollectionB", "b");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ get {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because deleteAllGlobalEnvVars does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
bru.setGlobalEnvVar("testDelAllGlobalA", "a");
|
||||
bru.setGlobalEnvVar("testDelAllGlobalB", "b");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ get {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because deleteCollectionVar does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
bru.setCollectionVar("testDeleteCollectionVar", "to-be-deleted");
|
||||
bru.deleteCollectionVar("testDeleteCollectionVar");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ get {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because deleteGlobalEnvVar does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
bru.setGlobalEnvVar("testDeleteGlobalEnvVar", "to-be-deleted");
|
||||
bru.deleteGlobalEnvVar("testDeleteGlobalEnvVar");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ get {
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because getAllCollectionVars does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
return;
|
||||
bru.setCollectionVar("testCollectionA", "valueA");
|
||||
bru.setCollectionVar("testCollectionB", "valueB");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ get {
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// TODO: skipped because setCollectionVar does not update the UI
|
||||
bru.runner.skipRequest();
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setCollectionVar("testSetCollectionVar", "collection-test-value")
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ test.describe('Default Location Feature', () => {
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
|
||||
// Wait for inline creator to appear, then click the cog button to open advanced modal
|
||||
const inlineCreator = page.locator('.inline-collection-creator');
|
||||
await inlineCreator.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await inlineCreator.locator('.cog-btn').click();
|
||||
|
||||
// Wait for modal to be visible
|
||||
await page.locator('.bruno-modal').waitFor({ state: 'visible' });
|
||||
|
||||
|
||||
@@ -68,7 +68,13 @@ const createCollection = async (page, collectionName: string, collectionLocation
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
|
||||
// Wait for inline creator to appear, then click the cog button to open advanced modal
|
||||
const inlineCreator = page.locator('.inline-collection-creator');
|
||||
await inlineCreator.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await inlineCreator.locator('.cog-btn').click();
|
||||
|
||||
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
|
||||
await createCollectionModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
await createCollectionModal.getByLabel('Name').fill(collectionName);
|
||||
const locationInput = createCollectionModal.getByLabel('Location');
|
||||
|
||||
Reference in New Issue
Block a user