mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 04:35:40 +00:00
Merge branch 'main' into feat/websocket-engine
This commit is contained in:
@@ -20,8 +20,8 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
parser: require('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
files: [
|
||||
'./eslint.config.js',
|
||||
@@ -44,11 +44,11 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
semi: true,
|
||||
arrowParens: false,
|
||||
jsx: true,
|
||||
}).rules,
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||
'@stylistic/arrow-parens': ['error', 'as-needed'],
|
||||
'@stylistic/arrow-parens': ['error', 'always'],
|
||||
'@stylistic/curly-newline': ['error', {
|
||||
multiline: true,
|
||||
minElements: 2,
|
||||
@@ -60,6 +60,7 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'@stylistic/function-call-spacing': ['error', 'never'],
|
||||
'@stylistic/multiline-ternary': ['off'],
|
||||
'@stylistic/padding-line-between-statements': ['off'],
|
||||
'@stylistic/jsx-one-expression-per-line': ['off'],
|
||||
'@stylistic/semi-style': ['error', 'last'],
|
||||
'@stylistic/max-len': ['off'],
|
||||
'@stylistic/jsx-one-expression-per-line': ['off'],
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -20286,6 +20286,18 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pidusage": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
|
||||
"integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pify": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
|
||||
@@ -30248,6 +30260,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"pidusage": "^4.0.1",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
IconCode,
|
||||
IconChevronDown,
|
||||
IconTerminal2,
|
||||
IconNetwork
|
||||
IconNetwork,
|
||||
IconDashboard,
|
||||
} from '@tabler/icons';
|
||||
import {
|
||||
closeConsole,
|
||||
@@ -24,10 +25,12 @@ import {
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
|
||||
import NetworkTab from './NetworkTab';
|
||||
import RequestDetailsPanel from './RequestDetailsPanel';
|
||||
// import DebugTab from './DebugTab';
|
||||
import ErrorDetailsPanel from './ErrorDetailsPanel';
|
||||
import Performance from '../Performance';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const LogIcon = ({ type }) => {
|
||||
@@ -384,6 +387,8 @@ const Console = () => {
|
||||
);
|
||||
case 'network':
|
||||
return <NetworkTab />;
|
||||
case 'performance':
|
||||
return <Performance />;
|
||||
// case 'debug':
|
||||
// return <DebugTab />;
|
||||
default:
|
||||
@@ -484,6 +489,14 @@ const Console = () => {
|
||||
<span>Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`console-tab ${activeTab === 'performance' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('performance')}
|
||||
>
|
||||
<IconDashboard size={16} strokeWidth={1.5} />
|
||||
<span>Performance</span>
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('debug')}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${props => props.theme.console.bg};
|
||||
}
|
||||
|
||||
.tab-content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.overview-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid ${props => props.theme.console.border};
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: ${props => props.theme.console.textMuted};
|
||||
}
|
||||
}
|
||||
|
||||
.system-resources {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
}
|
||||
}
|
||||
|
||||
.resource-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
background: ${props => props.theme.console.headerBg};
|
||||
border: 1px solid ${props => props.theme.console.border};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.resource-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.resource-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.theme.console.titleColor};
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.resource-subtitle {
|
||||
font-size: 11px;
|
||||
color: ${props => props.theme.console.buttonColor};
|
||||
}
|
||||
|
||||
.resource-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
margin-top: 8px;
|
||||
|
||||
&.up {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.down {
|
||||
color: #e81123;
|
||||
}
|
||||
|
||||
&.stable {
|
||||
color: ${props => props.theme.console.buttonColor};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
100
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
100
packages/bruno-app/src/components/Devtools/Performance/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import {
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconClock,
|
||||
IconServer,
|
||||
IconChartLine,
|
||||
} from '@tabler/icons';
|
||||
|
||||
const Performance = () => {
|
||||
const { systemResources } = useSelector(state => state.performance);
|
||||
|
||||
const formatBytes = bytes => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUptime = seconds => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
const SystemResourceCard = ({ icon: Icon, title, value, subtitle, color = 'default', trend }) => (
|
||||
<div className={`resource-card ${color}`}>
|
||||
<div className="resource-header">
|
||||
<Icon size={20} strokeWidth={1.5} />
|
||||
<span className="resource-title">{title}</span>
|
||||
</div>
|
||||
<div className="resource-value">{value}</div>
|
||||
{subtitle && <div className="resource-subtitle">{subtitle}</div>}
|
||||
{trend && (
|
||||
<div className={`resource-trend ${trend > 0 ? 'up' : trend < 0 ? 'down' : 'stable'}`}>
|
||||
<IconChartLine size={12} strokeWidth={1.5} />
|
||||
<span>
|
||||
{trend > 0 ? '+' : ''}
|
||||
{trend.toFixed(1)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="tab-content">
|
||||
<div className="tab-content-area">
|
||||
<div className="system-resources">
|
||||
<h2>System Resources</h2>
|
||||
<div className="resource-cards">
|
||||
<SystemResourceCard
|
||||
icon={IconCpu}
|
||||
title="CPU Usage"
|
||||
value={`${systemResources.cpu.toFixed(1)}%`}
|
||||
subtitle="Current process"
|
||||
color={systemResources.cpu > 80 ? 'danger' : systemResources.cpu > 60 ? 'warning' : 'success'}
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconDatabase}
|
||||
title="Memory Usage"
|
||||
value={formatBytes(systemResources.memory)}
|
||||
subtitle="Current process"
|
||||
color={systemResources.memory > 500 * 1024 * 1024 ? 'danger' : 'default'}
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconClock}
|
||||
title="Uptime"
|
||||
value={formatUptime(systemResources.uptime)}
|
||||
subtitle="Process runtime"
|
||||
color="info"
|
||||
/>
|
||||
|
||||
<SystemResourceCard
|
||||
icon={IconServer}
|
||||
title="Process ID"
|
||||
value={systemResources.pid || 'N/A'}
|
||||
subtitle="Current PID"
|
||||
color="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Performance;
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import { useFormik } from 'formik';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -35,7 +36,8 @@ const General = ({ close }) => {
|
||||
})
|
||||
.test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => {
|
||||
return value === undefined || Number(value) >= 0;
|
||||
})
|
||||
}),
|
||||
defaultCollectionLocation: Yup.string().max(1024)
|
||||
});
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -50,7 +52,8 @@ const General = ({ close }) => {
|
||||
},
|
||||
timeout: preferences.request.timeout,
|
||||
storeCookies: get(preferences, 'request.storeCookies', true),
|
||||
sendCookies: get(preferences, 'request.sendCookies', true)
|
||||
sendCookies: get(preferences, 'request.sendCookies', true),
|
||||
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
|
||||
},
|
||||
validationSchema: preferencesSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -79,6 +82,9 @@ const General = ({ close }) => {
|
||||
timeout: newPreferences.timeout,
|
||||
storeCookies: newPreferences.storeCookies,
|
||||
sendCookies: newPreferences.sendCookies
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: newPreferences.defaultCollectionLocation
|
||||
}
|
||||
}))
|
||||
.then(() => {
|
||||
@@ -99,6 +105,19 @@ const General = ({ close }) => {
|
||||
formik.setFieldValue('customCaCertificate.filePath', null);
|
||||
};
|
||||
|
||||
const browseDefaultLocation = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('defaultCollectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('defaultCollectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
@@ -231,6 +250,35 @@ const General = ({ close }) => {
|
||||
{formik.touched.timeout && formik.errors.timeout ? (
|
||||
<div className="text-red-500">{formik.errors.timeout}</div>
|
||||
) : null}
|
||||
<div className="flex flex-col mt-6">
|
||||
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
|
||||
Default Collection Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="defaultCollectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.defaultCollectionLocation || ''}
|
||||
onClick={browseDefaultLocation}
|
||||
placeholder="Click to browse for default location"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline default-collection-location-browse"
|
||||
onClick={browseDefaultLocation}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-10">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
Save
|
||||
|
||||
@@ -12,12 +12,15 @@ import PathDisplay from 'components/PathDisplay';
|
||||
import { useState } from 'react';
|
||||
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
|
||||
import { findCollectionByUid } from 'utils/collections/index';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
|
||||
const { name } = collection;
|
||||
|
||||
const formik = useFormik({
|
||||
@@ -25,7 +28,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
|
||||
initialValues: {
|
||||
collectionName: `${name} copy`,
|
||||
collectionFolderName: `${sanitizeName(name)} copy`,
|
||||
collectionLocation: ''
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionName: Yup.string()
|
||||
|
||||
@@ -6,7 +6,7 @@ import filter from 'lodash/filter';
|
||||
import { useDrop, useDrag } from 'react-dnd';
|
||||
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { toggleCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
@@ -132,6 +132,10 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollapseFullCollection = () => {
|
||||
dispatch(collapseFullCollection({ collectionUid: collection.uid }));
|
||||
};
|
||||
|
||||
const viewCollectionSettings = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
@@ -252,7 +256,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
|
||||
</div>
|
||||
<div className="collection-actions">
|
||||
<div className="collection-actions" data-testid="collection-actions">
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
@@ -274,6 +278,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
data-testid="clone-collection"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
@@ -310,6 +315,15 @@ const Collection = ({ collection, searchText }) => {
|
||||
>
|
||||
Share
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
handleCollapseFullCollection();
|
||||
}}
|
||||
>
|
||||
Collapse
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
|
||||
@@ -19,7 +19,10 @@ const CreateOrOpenCollection = () => {
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
|
||||
(err) => {
|
||||
console.log(err);
|
||||
toast.error('An error occurred while opening the collection');
|
||||
}
|
||||
);
|
||||
};
|
||||
const CreateLink = () => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -14,18 +14,21 @@ import Help from 'components/Help';
|
||||
import { multiLineMsg } from "utils/common";
|
||||
import { formatIpcError } from "utils/common/error";
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
collectionName: '',
|
||||
collectionFolderName: '',
|
||||
collectionLocation: ''
|
||||
collectionLocation: defaultLocation
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
collectionName: Yup.string()
|
||||
|
||||
@@ -55,7 +55,10 @@ const TitleBar = () => {
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch(
|
||||
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
|
||||
(err) => {
|
||||
console.log(err);
|
||||
toast.error('An error occurred while opening the collection');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ const StatusBar = () => {
|
||||
|
||||
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
className="status-bar-button preferences-button"
|
||||
data-trigger="preferences"
|
||||
onClick={() => dispatch(showPreferences(true))}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -78,7 +78,7 @@ const Welcome = () => {
|
||||
aria-label={t('WELCOME.CREATE_COLLECTION')}
|
||||
>
|
||||
<IconPlus aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2" id="create-collection">
|
||||
<span className="label ml-2" id="create-collection" data-testid="create-collection">
|
||||
{t('WELCOME.CREATE_COLLECTION')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isElectron } from 'utils/common/platform';
|
||||
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { addLog } from 'providers/ReduxStore/slices/logs';
|
||||
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
|
||||
|
||||
const useIpcEvents = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -145,6 +146,10 @@ const useIpcEvents = () => {
|
||||
}));
|
||||
});
|
||||
|
||||
const removeSystemResourcesListener = ipcRenderer.on('main:filesync-system-resources', resourceData => {
|
||||
dispatch(updateSystemResources(resourceData));
|
||||
});
|
||||
|
||||
const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) =>
|
||||
dispatch(brunoConfigUpdateEvent(val))
|
||||
);
|
||||
@@ -209,6 +214,7 @@ const useIpcEvents = () => {
|
||||
removeCollectionOauth2CredentialsUpdatesListener();
|
||||
removeCollectionLoadingStateListener();
|
||||
removePersistentEnvVariablesUpdateListener();
|
||||
removeSystemResourcesListener();
|
||||
};
|
||||
}, [isElectron]);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import tabsReducer from './slices/tabs';
|
||||
import notificationsReducer from './slices/notifications';
|
||||
import globalEnvironmentsReducer from './slices/global-environments';
|
||||
import logsReducer from './slices/logs';
|
||||
import performanceReducer from './slices/performance';
|
||||
import { draftDetectMiddleware } from './middlewares/draft/middleware';
|
||||
|
||||
const isDevEnv = () => {
|
||||
@@ -25,7 +26,8 @@ export const store = configureStore({
|
||||
tabs: tabsReducer,
|
||||
notifications: notificationsReducer,
|
||||
globalEnvironments: globalEnvironmentsReducer,
|
||||
logs: logsReducer
|
||||
logs: logsReducer,
|
||||
performance: performanceReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ const initialState = {
|
||||
font: {
|
||||
codeFont: 'default'
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: ''
|
||||
},
|
||||
beta: {
|
||||
grpc: false,
|
||||
websocket: false,
|
||||
|
||||
@@ -133,6 +133,13 @@ export const collectionsSlice = createSlice({
|
||||
state.collections.push(collection);
|
||||
}
|
||||
},
|
||||
collapseFullCollection: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
const collection = findCollectionByUid(state.collections, collectionUid);
|
||||
if (collection) {
|
||||
collapseAllItemsInCollection(collection);
|
||||
}
|
||||
},
|
||||
updateCollectionMountStatus: (state, action) => {
|
||||
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
|
||||
if (collection) {
|
||||
@@ -2931,6 +2938,7 @@ export const {
|
||||
saveRequest,
|
||||
deleteRequestDraft,
|
||||
newEphemeralHttpRequest,
|
||||
collapseFullCollection,
|
||||
toggleCollection,
|
||||
toggleCollectionItem,
|
||||
requestUrlChanged,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
systemResources: {
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
pid: null,
|
||||
uptime: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const performanceSlice = createSlice({
|
||||
name: 'performance',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateSystemResources: (state, action) => {
|
||||
state.systemResources = {
|
||||
...state.systemResources,
|
||||
...action.payload,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateSystemResources } = performanceSlice.actions;
|
||||
export default performanceSlice.reducer;
|
||||
@@ -12,31 +12,130 @@ let CodeMirror;
|
||||
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
|
||||
const { get } = require('lodash');
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
const COPY_ICON_SVG_TEXT = `
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const renderVarInfo = (token, options, cm, pos) => {
|
||||
// Extract variable name and value based on token
|
||||
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
|
||||
const CHECKMARK_ICON_SVG_TEXT = `
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20,6 9,17 4,12"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
if (variableValue === undefined) {
|
||||
const COPY_SUCCESS_COLOR = '#22c55e';
|
||||
|
||||
export const COPY_SUCCESS_TIMEOUT = 1000;
|
||||
|
||||
const getCopyButton = variableValue => {
|
||||
const copyButton = document.createElement('button');
|
||||
|
||||
copyButton.className = 'copy-button';
|
||||
copyButton.style.backgroundColor = 'transparent';
|
||||
copyButton.style.border = 'none';
|
||||
copyButton.style.color = 'inherit';
|
||||
copyButton.style.cursor = 'pointer';
|
||||
copyButton.style.padding = '2px';
|
||||
copyButton.style.opacity = '0.7';
|
||||
copyButton.style.transition = 'opacity 0.2s ease';
|
||||
copyButton.style.display = 'flex';
|
||||
copyButton.style.alignItems = 'center';
|
||||
copyButton.style.justifyContent = 'center';
|
||||
|
||||
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
|
||||
|
||||
let isCopied = false;
|
||||
|
||||
copyButton.addEventListener('mouseenter', () => {
|
||||
if (isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const into = document.createElement('div');
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.className = 'info-description';
|
||||
copyButton.style.opacity = '1';
|
||||
});
|
||||
|
||||
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
|
||||
descriptionDiv.appendChild(document.createTextNode('*****'));
|
||||
} else {
|
||||
descriptionDiv.appendChild(document.createTextNode(variableValue));
|
||||
copyButton.addEventListener('mouseleave', () => {
|
||||
if (isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
into.appendChild(descriptionDiv);
|
||||
copyButton.style.opacity = '0.7';
|
||||
});
|
||||
|
||||
return into;
|
||||
};
|
||||
copyButton.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Prevent clicking if showing success checkmark
|
||||
if (isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(variableValue)
|
||||
.then(() => {
|
||||
isCopied = true;
|
||||
copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT;
|
||||
copyButton.style.opacity = '1';
|
||||
copyButton.style.color = COPY_SUCCESS_COLOR;
|
||||
copyButton.style.cursor = 'default';
|
||||
copyButton.classList.add('copy-success');
|
||||
|
||||
setTimeout(() => {
|
||||
isCopied = false;
|
||||
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
|
||||
copyButton.style.opacity = '0.7';
|
||||
copyButton.style.color = 'inherit';
|
||||
copyButton.style.cursor = 'pointer';
|
||||
copyButton.classList.remove('copy-success');
|
||||
}, COPY_SUCCESS_TIMEOUT);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy to clipboard:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
return copyButton;
|
||||
};
|
||||
|
||||
export const renderVarInfo = (token, options, cm, pos) => {
|
||||
// Extract variable name and value based on token
|
||||
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
|
||||
|
||||
if (variableValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const into = document.createElement('div');
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.display = 'flex';
|
||||
contentDiv.style.alignItems = 'center';
|
||||
contentDiv.style.gap = '8px';
|
||||
contentDiv.className = 'info-content';
|
||||
|
||||
const descriptionDiv = document.createElement('div');
|
||||
descriptionDiv.className = 'info-description';
|
||||
descriptionDiv.style.flex = '1';
|
||||
|
||||
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
|
||||
descriptionDiv.appendChild(document.createTextNode('*****'));
|
||||
} else {
|
||||
descriptionDiv.appendChild(document.createTextNode(variableValue));
|
||||
}
|
||||
|
||||
const copyButton = getCopyButton(variableValue);
|
||||
|
||||
contentDiv.appendChild(descriptionDiv);
|
||||
contentDiv.appendChild(copyButton);
|
||||
into.appendChild(contentDiv);
|
||||
|
||||
return into;
|
||||
};
|
||||
|
||||
if (!SERVER_RENDERED) {
|
||||
CodeMirror = require('codemirror');
|
||||
|
||||
CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) {
|
||||
if (old && old !== CodeMirror.Init) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { interpolate } from '@usebruno/common';
|
||||
import { extractVariableInfo } from './brunoVarInfo';
|
||||
import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@usebruno/common', () => ({
|
||||
@@ -225,3 +225,120 @@ describe('extractVariableInfo', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVarInfo', () => {
|
||||
let clipboardText = '';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// setup mock clipboard
|
||||
clipboardText = '';
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: jest.fn(text => {
|
||||
if (text === 'cause-clipboard-error') {
|
||||
return Promise.reject(new Error('Clipboard error'));
|
||||
}
|
||||
|
||||
clipboardText = text;
|
||||
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// mock console.error
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function setupRender(variables) {
|
||||
const result = renderVarInfo({ string: '{{apiKey}}' }, { variables });
|
||||
const contentDiv = result.querySelector('.info-content');
|
||||
const descriptionDiv = contentDiv.querySelector('.info-description');
|
||||
const copyButton = contentDiv.querySelector('.copy-button');
|
||||
|
||||
return { result, contentDiv, descriptionDiv, copyButton };
|
||||
}
|
||||
|
||||
describe('popup functionality', () => {
|
||||
it('should create a popup', () => {
|
||||
const { result } = setupRender({ apiKey: 'test-value' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a popup with the correct variable name and value', () => {
|
||||
const { descriptionDiv } = setupRender({ apiKey: 'test-value' });
|
||||
|
||||
expect(descriptionDiv.textContent).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should correctly mask the variable value in the popup', () => {
|
||||
const { descriptionDiv } = setupRender({
|
||||
apiKey: 'test-value',
|
||||
maskedEnvVariables: ['apiKey'],
|
||||
});
|
||||
|
||||
expect(descriptionDiv.textContent).toBe('*****');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy button functionality', () => {
|
||||
it('should create a copy button', () => {
|
||||
const { copyButton } = setupRender({ apiKey: 'test-value' });
|
||||
|
||||
expect(copyButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should copy the variable value to the clipboard', async () => {
|
||||
const { copyButton } = setupRender({ apiKey: 'test-value' });
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
expect(clipboardText).toBe('test-value');
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
|
||||
});
|
||||
|
||||
it('should copy the variable value of masked variables to the clipboard', async () => {
|
||||
const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] });
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
expect(clipboardText).toBe('test-value');
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
|
||||
});
|
||||
|
||||
it('should show a success checkmark when the variable value is copied', async () => {
|
||||
const { copyButton } = setupRender({ apiKey: 'test-value' });
|
||||
|
||||
expect(copyButton.classList.contains('copy-success')).toBe(false);
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
expect(copyButton.classList.contains('copy-success')).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT);
|
||||
|
||||
expect(copyButton.classList.contains('copy-success')).toBe(false);
|
||||
});
|
||||
|
||||
it('should log to the console when the variable value is not copied', async () => {
|
||||
const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' });
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
// wait for .catch() microtask to run
|
||||
await Promise.resolve();
|
||||
|
||||
expect(clipboardText).toBe('');
|
||||
expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"pidusage": "^4.0.1",
|
||||
"qs": "^6.11.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tough-cookie": "^6.0.0",
|
||||
|
||||
@@ -42,15 +42,36 @@ const getCollectionConfigFile = async (pathname) => {
|
||||
};
|
||||
|
||||
const openCollectionDialog = async (win, watcher) => {
|
||||
const { filePaths } = await dialog.showOpenDialog(win, {
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||
properties: ['openDirectory', 'createDirectory', 'multiSelections']
|
||||
});
|
||||
if (filePaths && filePaths[0]) {
|
||||
const resolvedPath = path.resolve(filePaths[0]);
|
||||
if (isDirectory(resolvedPath)) {
|
||||
openCollection(win, watcher, resolvedPath);
|
||||
} else {
|
||||
console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`);
|
||||
|
||||
if (!canceled && filePaths?.length > 0) {
|
||||
// Using Set to remove duplicates
|
||||
const { openCollectionPromises, invalidPaths } = [...new Set(filePaths)].reduce((acc, filePath) => {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
if (isDirectory(resolvedPath)) {
|
||||
// Open each valid collection in parallel
|
||||
acc.openCollectionPromises.push(openCollection(win, watcher, resolvedPath).catch((err) => {
|
||||
console.error(`[ERROR] Failed to open collection at "${resolvedPath}":`, err.message);
|
||||
return { error: err, path: resolvedPath };
|
||||
}));
|
||||
} else {
|
||||
acc.invalidPaths.push(resolvedPath);
|
||||
console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ openCollectionPromises: [], invalidPaths: [] });
|
||||
|
||||
// Wait for all valid collections to be opened
|
||||
await Promise.all(openCollectionPromises);
|
||||
|
||||
// Notify about any invalid paths
|
||||
if (invalidPaths.length > 0) {
|
||||
win.webContents.send('main:display-error', `Some selected folders could not be opened: ${invalidPaths.join(', ')}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -78,7 +99,7 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
|
||||
} catch (err) {
|
||||
if (!options.dontSendDisplayErrors) {
|
||||
win.webContents.send('main:display-error', {
|
||||
error: err.message || 'An error occurred while opening the local collection'
|
||||
message: err.message || 'An error occurred while opening the local collection'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
71
packages/bruno-electron/src/app/system-monitor.js
Normal file
71
packages/bruno-electron/src/app/system-monitor.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const pidusage = require('pidusage');
|
||||
|
||||
class SystemMonitor {
|
||||
constructor() {
|
||||
this.intervalId = null;
|
||||
this.isMonitoring = false;
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
start(win, intervalMs = 2000) {
|
||||
if (this.isMonitoring) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMonitoring = true;
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Emit initial stats
|
||||
this.emitSystemStats(win);
|
||||
|
||||
// Set up periodic monitoring
|
||||
this.intervalId = setInterval(() => {
|
||||
this.emitSystemStats(win);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.isMonitoring = false;
|
||||
}
|
||||
|
||||
async emitSystemStats(win) {
|
||||
try {
|
||||
const pid = process.pid;
|
||||
const stats = await pidusage(pid);
|
||||
const uptime = (Date.now() - this.startTime) / 1000;
|
||||
|
||||
const systemResources = {
|
||||
cpu: stats.cpu || 0,
|
||||
memory: stats.memory || 0,
|
||||
pid: pid,
|
||||
uptime: uptime,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
win.webContents.send('main:filesync-system-resources', systemResources);
|
||||
} catch (error) {
|
||||
console.error('Error getting system stats:', error);
|
||||
|
||||
// Fallback stats if pidusage fails
|
||||
const fallbackStats = {
|
||||
cpu: 0,
|
||||
memory: process.memoryUsage().rss,
|
||||
pid: process.pid,
|
||||
uptime: (Date.now() - this.startTime) / 1000,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
win.webContents.send('main:filesync-system-resources', fallbackStats);
|
||||
}
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.isMonitoring;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SystemMonitor;
|
||||
@@ -45,8 +45,10 @@ const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
|
||||
const { getDomainsWithCookies } = require('./utils/cookies');
|
||||
const { cookiesStore } = require('./store/cookies');
|
||||
const onboardUser = require('./app/onboarding');
|
||||
const SystemMonitor = require('./app/system-monitor');
|
||||
|
||||
const lastOpenedCollections = new LastOpenedCollections();
|
||||
const systemMonitor = new SystemMonitor();
|
||||
|
||||
// Reference: https://content-security-policy.com/
|
||||
const contentSecurityPolicy = [
|
||||
@@ -202,6 +204,9 @@ app.on('ready', async () => {
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('main:app-loaded');
|
||||
|
||||
// Start system monitoring for FileSync
|
||||
systemMonitor.start(mainWindow);
|
||||
});
|
||||
|
||||
// register all ipc handlers
|
||||
@@ -220,6 +225,9 @@ app.on('before-quit', () => {
|
||||
} catch (err) {
|
||||
console.warn('Failed to flush cookies on quit', err);
|
||||
}
|
||||
|
||||
// Stop system monitoring
|
||||
systemMonitor.stop();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', app.quit);
|
||||
|
||||
@@ -47,6 +47,9 @@ const defaultPreferences = {
|
||||
},
|
||||
onboarding: {
|
||||
hasLaunchedBefore: false
|
||||
},
|
||||
general: {
|
||||
defaultCollectionLocation: ''
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,6 +92,9 @@ const preferencesSchema = Yup.object().shape({
|
||||
}),
|
||||
onboarding: Yup.object({
|
||||
hasLaunchedBefore: Yup.boolean()
|
||||
}),
|
||||
general: Yup.object({
|
||||
defaultCollectionLocation: Yup.string().max(1024).nullable()
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Tag persistence', () => {
|
||||
test.afterAll(async ({ pageWithUserData: page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Verify tag persistence while moving requests within a collection', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
// Create first collection - click dropdown menu first
|
||||
await page.getByLabel('Create Collection').click();
|
||||
|
||||
108
tests/collection/open/open-multiple-collections.spec.ts
Normal file
108
tests/collection/open/open-multiple-collections.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Open Multiple Collections', () => {
|
||||
let originalShowOpenDialog;
|
||||
|
||||
test.beforeAll(async ({ electronApp }) => {
|
||||
// save the original showOpenDialog function
|
||||
await electronApp.evaluate(({ dialog }) => {
|
||||
originalShowOpenDialog = dialog.showOpenDialog;
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async ({ electronApp }) => {
|
||||
// restore the original showOpenDialog function
|
||||
await electronApp.evaluate(({ dialog }) => {
|
||||
dialog.showOpenDialog = originalShowOpenDialog;
|
||||
});
|
||||
});
|
||||
|
||||
test('Should open multiple collections using Open Collection feature', async ({
|
||||
page,
|
||||
electronApp,
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Create two test collections with proper bruno.json files
|
||||
const collection1Dir = await createTmpDir('collection-1');
|
||||
const collection2Dir = await createTmpDir('collection-2');
|
||||
|
||||
// Create bruno.json for first collection
|
||||
const collection1Config = {
|
||||
version: '1',
|
||||
name: 'Test Collection 1',
|
||||
type: 'collection'
|
||||
};
|
||||
// Create bruno.json for second collection
|
||||
const collection2Config = {
|
||||
version: '1',
|
||||
name: 'Test Collection 2',
|
||||
type: 'collection'
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(collection1Dir, 'bruno.json'), JSON.stringify(collection1Config, null, 2));
|
||||
fs.writeFileSync(path.join(collection2Dir, 'bruno.json'), JSON.stringify(collection2Config, null, 2));
|
||||
|
||||
// Mock the electron dialog to return multiple folder selections
|
||||
await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => {
|
||||
dialog.showOpenDialog = async () => ({
|
||||
canceled: false,
|
||||
filePaths: [collection1Dir, collection2Dir]
|
||||
});
|
||||
},
|
||||
{ collection1Dir, collection2Dir });
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
|
||||
|
||||
// Click on Open Collection(s) button
|
||||
await page.getByRole('button', { name: 'Open Collection' }).click();
|
||||
|
||||
// Wait for both collections to appear in the sidebar
|
||||
const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1');
|
||||
const collection2Element = page.locator('#sidebar-collection-name').getByText('Test Collection 2');
|
||||
|
||||
await expect(collection1Element).toBeVisible();
|
||||
await expect(collection2Element).toBeVisible();
|
||||
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Should handle invalid collection path and display error', async ({
|
||||
page,
|
||||
electronApp,
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Directory without bruno.json file
|
||||
const collection1Dir = await createTmpDir('collection-1');
|
||||
const collection2Dir = 'invalid-collection-path';
|
||||
|
||||
// Mock the electron dialog to return multiple folder selections
|
||||
await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => {
|
||||
dialog.showOpenDialog = async () => ({
|
||||
canceled: false,
|
||||
filePaths: [collection1Dir, collection2Dir]
|
||||
});
|
||||
},
|
||||
{ collection1Dir, collection2Dir });
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
|
||||
|
||||
// Click on Open Collection(s) button
|
||||
await page.getByRole('button', { name: 'Open Collection' }).click();
|
||||
|
||||
// Verify no collections were opened
|
||||
await expect(page.locator('#sidebar-collection-name')).toHaveCount(0);
|
||||
|
||||
// Verify invalid collection error
|
||||
const invalidCollectionError = page.getByText('The collection is not valid (bruno.json not found)').first();
|
||||
await expect(invalidCollectionError).toBeVisible();
|
||||
|
||||
// Verify invalid path error
|
||||
const invalidPathError = page.getByText('Some selected folders could not be opened').getByText('invalid-collection-path').first();
|
||||
await expect(invalidPathError).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "collection",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
meta {
|
||||
name: collection
|
||||
type: collection
|
||||
version: 1.0.0
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
vars {
|
||||
host: https://www.httpfaker.org
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo
|
||||
body: text
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Default Collection Location Feature', () => {
|
||||
test('Should hydrate the default location from preferences', async ({ pageWithUserData: page }) => {
|
||||
// open preferences
|
||||
await page.locator('.preferences-button').click();
|
||||
|
||||
// verify the default location is pre-filled
|
||||
const defaultLocationInput = page.locator('.default-collection-location-input');
|
||||
await expect(defaultLocationInput).toHaveValue('/tmp/bruno-collections');
|
||||
|
||||
// close the preferences
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
|
||||
// wait for 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('Should save empty default location', async ({ pageWithUserData: page }) => {
|
||||
// open preferences
|
||||
await page.locator('.preferences-button').click();
|
||||
|
||||
// clear the default location field
|
||||
const defaultLocationInput = page.locator('.default-collection-location-input');
|
||||
await defaultLocationInput.clear();
|
||||
|
||||
// save preferences
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// verify success message
|
||||
await expect(page.locator('text=Preferences saved successfully')).toBeVisible();
|
||||
|
||||
// wait for 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('Should save a valid default location', async ({ pageWithUserData: page }) => {
|
||||
// open preferences
|
||||
await page.locator('.preferences-button').click();
|
||||
|
||||
// set a default location
|
||||
const defaultLocationInput = page.locator('.default-collection-location-input');
|
||||
|
||||
// fill the default location input
|
||||
await defaultLocationInput.fill('/tmp/bruno-collections');
|
||||
|
||||
// save preferences
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// verify success message
|
||||
await expect(page.locator('text=Preferences saved successfully')).toBeVisible();
|
||||
|
||||
// wait for 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => {
|
||||
// test Create Collection modal
|
||||
await page.locator('[data-testid="create-collection"]').click();
|
||||
|
||||
// verify the default location is pre-filled
|
||||
const collectionLocationInput = page.getByLabel('Location');
|
||||
await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections');
|
||||
|
||||
// cancel the collection creation
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// wait for 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => {
|
||||
// open the clone collection modal
|
||||
await page.locator('[data-testid="collection-actions"]').click();
|
||||
await page.getByTestId('clone-collection').click();
|
||||
|
||||
// verify the default location is pre-filled
|
||||
const cloneLocationInput = page.getByLabel('Location');
|
||||
await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections');
|
||||
|
||||
// wait for 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/preferences/default-collection-location/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "developer"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": ["{{projectRoot}}/tests/preferences/default-collection-location/collection"],
|
||||
"preferences": {
|
||||
"general": {
|
||||
"defaultCollectionLocation": "/tmp/bruno-collections"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tests/utils/page/actions.ts
Normal file
11
tests/utils/page/actions.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const closeAllCollections = async (page) => {
|
||||
const numberOfCollections = await page.locator('.collection-name').count();
|
||||
|
||||
for (let i = 0; i < numberOfCollections; i++) {
|
||||
await page.locator('.collection-name').first().locator('.collection-actions').click();
|
||||
await page.locator('.dropdown-item').getByText('Close').click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
}
|
||||
};
|
||||
|
||||
export { closeAllCollections };
|
||||
1
tests/utils/page/index.ts
Normal file
1
tests/utils/page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './actions';
|
||||
Reference in New Issue
Block a user