mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 10:28:32 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068900866c | ||
|
|
fa5ac0d460 | ||
|
|
c8da13bd9b | ||
|
|
86727c8525 | ||
|
|
901b6daaea | ||
|
|
87d8c5ccb7 | ||
|
|
17d5629627 | ||
|
|
4321846dbd | ||
|
|
f3d4ac84d8 | ||
|
|
de52ceea48 | ||
|
|
65e69e77b3 | ||
|
|
fb2ca8937e | ||
|
|
e2da072e8b | ||
|
|
90492d6e79 | ||
|
|
5393e3b496 | ||
|
|
9fc885839f | ||
|
|
dbfbde43cf | ||
|
|
1aa4e27ab5 | ||
|
|
2b6da56c3c | ||
|
|
c08827b0c0 | ||
|
|
841d977725 | ||
|
|
56629663dc | ||
|
|
27cbb194bf | ||
|
|
cfec4a9e1b | ||
|
|
a7f6d669af | ||
|
|
03abbc585f | ||
|
|
be730a8c4f | ||
|
|
194d904284 | ||
|
|
86b3c65dcd | ||
|
|
c9fe9813db | ||
|
|
70d65d87c5 | ||
|
|
0bce203851 |
2
.github/workflows/npm-bru-cli.yml
vendored
2
.github/workflows/npm-bru-cli.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: v22.11.x
|
||||
- name: Install dependencies
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -8739,12 +8739,6 @@
|
||||
"resolved": "packages/bruno-converters",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@usebruno/crypto-js": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@usebruno/crypto-js/-/crypto-js-3.1.9.tgz",
|
||||
"integrity": "sha512-khvEnRF6/UVDw4df06j+6lFWGNDYWlcWnxfmEgU2o/CdsGY291NC1Cexz99ud7sbGBQP2d8JUXZe4zXPkGNJpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@usebruno/filestore": {
|
||||
"resolved": "packages/bruno-filestore",
|
||||
"link": true
|
||||
@@ -31758,7 +31752,6 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/crypto-js": "^3.1.9",
|
||||
"@usebruno/query": "0.1.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
@@ -31768,7 +31761,7 @@
|
||||
"chai": "^4.3.7",
|
||||
"chai-string": "^1.5.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"json-query": "^2.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
|
||||
|
||||
const EnvironmentListContent = ({
|
||||
environments,
|
||||
activeEnvironmentUid,
|
||||
description,
|
||||
onEnvironmentSelect,
|
||||
onSettingsClick,
|
||||
onCreateClick,
|
||||
onImportClick
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{environments && environments.length > 0 ? (
|
||||
<>
|
||||
<div className="environment-list">
|
||||
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
|
||||
<span>No Environment</span>
|
||||
</div>
|
||||
<div>
|
||||
{environments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
|
||||
onClick={() => onEnvironmentSelect(env)}
|
||||
>
|
||||
<span className="max-w-32 truncate no-wrap">{env.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dropdown-item configure-button">
|
||||
<button onClick={onSettingsClick} id="configure-env">
|
||||
<IconSettings size={16} strokeWidth={1.5} />
|
||||
<span>Configure</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<h3>Ready to get started?</h3>
|
||||
<p>{description}</p>
|
||||
<div className="space-y-2">
|
||||
<button onClick={onCreateClick} id="create-env">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
Create
|
||||
</button>
|
||||
<button onClick={onImportClick} id="import-env">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentListContent;
|
||||
@@ -2,14 +2,227 @@ import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border-radius: 15px;
|
||||
border-radius: 0.9375rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
|
||||
user-select: none;
|
||||
background-color: transparent;
|
||||
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
|
||||
line-height: 1rem;
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
}
|
||||
|
||||
.env-icon {
|
||||
margin-right: 0.25rem;
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
}
|
||||
|
||||
.env-text {
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.env-separator {
|
||||
color: #8c8c8c;
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.env-text-inactive {
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.no-environments {
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border: 1px solid transparent;
|
||||
color: ${(props) => props.theme.dropdown.secondaryText};
|
||||
}
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
min-width: 11.875rem;
|
||||
min-height: 15.0625rem;
|
||||
max-height: 75vh;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tippy-box .tippy-content {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: ${(props) => props.theme.dropdown.selectedBg};
|
||||
color: ${(props) => props.theme.dropdown.selectedColor};
|
||||
}
|
||||
|
||||
&.no-environment {
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.configure-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: ${(props) => props.theme.dropdown.bg};
|
||||
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
|
||||
}
|
||||
|
||||
button {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
color: var(--color-tab-inactive);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.tab-content-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
border-bottom-color: ${(props) => props.theme.tabs.active.border};
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.environment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: calc(75vh - 8rem);
|
||||
padding-bottom: 2.625rem;
|
||||
}
|
||||
|
||||
.dropdown-item-list {
|
||||
max-height: 75vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.35rem 0.6rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 12.5rem;
|
||||
|
||||
h3 {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
opacity: 0.75;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 11.875rem;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.space-y-2 {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.space-y-2 > button {
|
||||
border: 0.0625rem solid ${(props) => props.theme.dropdown.primaryText};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-collection-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
opacity: 0.75;
|
||||
|
||||
svg {
|
||||
margin: 0 auto 1rem auto;
|
||||
color: ${(props) => props.theme.dropdown.primaryText};
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,95 +1,240 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import React, { useState, useRef, forwardRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import EnvironmentListContent from './EnvironmentListContent/index';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
|
||||
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
|
||||
import ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
|
||||
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
|
||||
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const { environments, activeEnvironmentUid } = collection;
|
||||
const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
|
||||
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
|
||||
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
|
||||
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
|
||||
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const activeGlobalEnvironment = activeGlobalEnvironmentUid
|
||||
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const environments = collection?.environments || [];
|
||||
const activeEnvironmentUid = collection?.activeEnvironmentUid;
|
||||
const activeCollectionEnvironment = activeEnvironmentUid
|
||||
? find(environments, (e) => e.uid === activeEnvironmentUid)
|
||||
: null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
|
||||
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
|
||||
];
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
};
|
||||
|
||||
// Get description based on active tab
|
||||
const description =
|
||||
activeTab === 'collection'
|
||||
? 'Create your first environment to begin working with your collection.'
|
||||
: 'Create your first global environment to begin working across collections.';
|
||||
|
||||
// Environment selection handler
|
||||
const handleEnvironmentSelect = (environment) => {
|
||||
const action =
|
||||
activeTab === 'collection'
|
||||
? selectEnvironment(environment ? environment.uid : null, collection.uid)
|
||||
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
|
||||
|
||||
dispatch(action)
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success('No Environments are active now');
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while selecting the environment');
|
||||
});
|
||||
};
|
||||
|
||||
// Settings handler
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
setShowCollectionSettings(true);
|
||||
} else {
|
||||
setShowGlobalSettings(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Create handler
|
||||
const handleCreateClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowCreateCollectionModal(true);
|
||||
} else {
|
||||
setShowCreateGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Import handler
|
||||
const handleImportClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
setShowImportCollectionModal(true);
|
||||
} else {
|
||||
setShowImportGlobalModal(true);
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const handleCloseSettings = () => {
|
||||
setShowGlobalSettings(false);
|
||||
setShowCollectionSettings(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
// Create icon component for dropdown trigger
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
|
||||
|
||||
const displayContent = hasAnyEnv ? (
|
||||
<>
|
||||
{activeCollectionEnvironment && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<span className="env-text max-w-24 truncate no-wrap">{activeCollectionEnvironment.name}</span>
|
||||
</div>
|
||||
{activeGlobalEnvironment && <span className="env-separator">|</span>}
|
||||
</>
|
||||
)}
|
||||
{activeGlobalEnvironment && (
|
||||
<div className="flex items-center">
|
||||
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
|
||||
<span className="env-text max-w-24 truncate no-wrap">{activeGlobalEnvironment.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="current-environment collection-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
|
||||
<p className="text-nowrap truncate max-w-32" title={activeEnvironment ? activeEnvironment.name : 'No Environment'}>{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
|
||||
!hasAnyEnv ? 'no-environments' : ''
|
||||
}`}
|
||||
data-testid="environment-selector-trigger"
|
||||
>
|
||||
{displayContent}
|
||||
<IconCaretDown className="caret" size={14} strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSettingsIconClick = () => {
|
||||
setOpenSettingsModal(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpenSettingsModal(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const onSelect = (environment) => {
|
||||
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success(`No Environments are active now`);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer environment-selector">
|
||||
<div className="environment-selector flex align-center cursor-pointer">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
|
||||
<div className="label-item font-medium">Collection Environments</div>
|
||||
{environments && environments.length
|
||||
? environments.map((e) => (
|
||||
<div
|
||||
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
|
||||
key={e.uid}
|
||||
onClick={() => {
|
||||
onSelect(e);
|
||||
dropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onSelect(null);
|
||||
}}
|
||||
>
|
||||
<IconDatabaseOff size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2">No Environment</span>
|
||||
{/* Tab Headers */}
|
||||
<div className="tab-header flex justify-center p-[0.75rem]">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
|
||||
activeTab === tab.id ? 'active' : 'inactive'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-testid={`env-tab-${tab.id}`}
|
||||
>
|
||||
<span className="tab-content-wrapper">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dropdown-item border-top" onClick={() => {
|
||||
handleSettingsIconClick();
|
||||
dropdownTippyRef.current.hide();
|
||||
}}>
|
||||
<div className="pr-2 text-gray-600" id="Configure">
|
||||
<IconSettings size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span>Configure</span>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="tab-content">
|
||||
<EnvironmentListContent
|
||||
environments={activeTab === 'collection' ? environments : globalEnvironments}
|
||||
activeEnvironmentUid={activeTab === 'collection' ? activeEnvironmentUid : activeGlobalEnvironmentUid}
|
||||
description={description}
|
||||
onEnvironmentSelect={handleEnvironmentSelect}
|
||||
onSettingsClick={handleSettingsClick}
|
||||
onCreateClick={handleCreateClick}
|
||||
onImportClick={handleImportClick}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{openSettingsModal && <EnvironmentSettings collection={collection} onClose={handleModalClose} />}
|
||||
|
||||
{/* Modals - Rendered outside dropdown to avoid conflicts */}
|
||||
{showGlobalSettings && (
|
||||
<GlobalEnvironmentSettings globalEnvironments={globalEnvironments} collection={collection} onClose={handleCloseSettings} />
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportGlobalModal && (
|
||||
<ImportGlobalEnvironment
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateCollectionModal && (
|
||||
<CreateEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportCollectionModal && (
|
||||
<ImportEnvironment
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ collection, onClose }) => {
|
||||
const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
|
||||
@@ -37,6 +37,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Environment created in collection');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
@@ -254,6 +254,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
|
||||
onClick={addVariable}
|
||||
id="add-variable"
|
||||
data-testid="add-variable"
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
@@ -261,15 +262,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit}>
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
|
||||
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Save
|
||||
</button>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset}>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
|
||||
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Reset
|
||||
</button>
|
||||
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate}>
|
||||
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
|
||||
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
|
||||
Activate
|
||||
</button>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { importEnvironment } from 'providers/ReduxStore/slices/collections/actio
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
|
||||
const ImportEnvironment = ({ collection, onClose }) => {
|
||||
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
@@ -36,17 +36,22 @@ const ImportEnvironment = ({ collection, onClose }) => {
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
|
||||
@@ -56,9 +56,8 @@ const EnvironmentSettings = ({ collection, onClose }) => {
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.current-environment {
|
||||
}
|
||||
.environment-active {
|
||||
padding: 0.3rem 0.4rem;
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
|
||||
}
|
||||
.environment-selector {
|
||||
.active: {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { useRef, forwardRef, useState } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
|
||||
import EnvironmentSettings from '../EnvironmentSettings';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentSelector = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
|
||||
|
||||
const Icon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={`current-environment global-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
|
||||
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
|
||||
<IconWorld className="globe" size={16} strokeWidth={1.5} />
|
||||
{
|
||||
activeEnvironment ? <div className='text-nowrap truncate max-w-32'>{activeEnvironment?.name}</div> : null
|
||||
}
|
||||
</ToolHint>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleSettingsIconClick = () => {
|
||||
setOpenSettingsModal(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpenSettingsModal(false);
|
||||
};
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const onSelect = (environment) => {
|
||||
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
|
||||
.then(() => {
|
||||
if (environment) {
|
||||
toast.success(`Environment changed to ${environment.name}`);
|
||||
} else {
|
||||
toast.success(`No Environments are active now`);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center cursor-pointer environment-selector mr-3">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
|
||||
<div className="label-item font-medium">Global Environments</div>
|
||||
{globalEnvironments && globalEnvironments.length
|
||||
? globalEnvironments.map((e) => (
|
||||
<div
|
||||
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
|
||||
key={e.uid}
|
||||
onClick={() => {
|
||||
onSelect(e);
|
||||
dropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
dropdownTippyRef.current.hide();
|
||||
onSelect(null);
|
||||
}}
|
||||
>
|
||||
<IconDatabaseOff size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2">No Environment</span>
|
||||
</div>
|
||||
<div className="dropdown-item border-top" onClick={() => {
|
||||
handleSettingsIconClick();
|
||||
dropdownTippyRef.current.hide();
|
||||
}}>
|
||||
<div className="pr-2 text-gray-600">
|
||||
<IconSettings size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<span>Configure</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentSelector;
|
||||
@@ -8,7 +8,7 @@ import Modal from 'components/Modal';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ onClose }) => {
|
||||
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
@@ -39,6 +39,10 @@ const CreateEnvironment = ({ onClose }) => {
|
||||
.then(() => {
|
||||
toast.success('Global environment created!');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useRef, useEffect } from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -12,11 +12,18 @@ import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const addButtonRef = useRef(null);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector(state => state.globalEnvironments);
|
||||
|
||||
let _collection = cloneDeep(collection);
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
@@ -93,7 +100,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty) {
|
||||
// Smooth scrolling to the changed parameter is temporarily disabled
|
||||
// Smooth scrolling to the changed parameter is temporarily disabled
|
||||
// due to UX issues when editing the first row in a long list of environment variables.
|
||||
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
@@ -145,10 +152,11 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
<ErrorMessage name={`${index}.name`} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap">
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<SingleLineEditor
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
isSecret={variable.secret}
|
||||
@@ -179,6 +187,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
ref={addButtonRef}
|
||||
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
|
||||
onClick={addVariable}
|
||||
data-testid="add-variable"
|
||||
>
|
||||
+ Add Variable
|
||||
</button>
|
||||
@@ -186,10 +195,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
|
||||
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
|
||||
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import RenameEnvironment from '../../RenameEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const [openEditModal, setOpenEditModal] = useState(false);
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
@@ -37,7 +37,7 @@ const EnvironmentDetails = ({ environment, setIsModified }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import ImportEnvironment from '../ImportEnvironment';
|
||||
import { isEqual } from 'lodash';
|
||||
import ToolHint from 'components/ToolHint/index';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
|
||||
@@ -143,6 +143,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { uuid } from 'utils/common/index';
|
||||
|
||||
const ImportEnvironment = ({ onClose }) => {
|
||||
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
@@ -37,17 +37,22 @@ const ImportEnvironment = ({ onClose }) => {
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-global-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ const DefaultTab = ({ setTab }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
|
||||
const EnvironmentSettings = ({ globalEnvironments, collection, onClose }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const environments = globalEnvironments;
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
@@ -53,9 +53,8 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<></>
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
<DefaultTab setTab={setTab} />
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -65,11 +64,11 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
|
||||
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -73,7 +73,8 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
const itemPathLower = itemPath.toLowerCase();
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
|
||||
// add an optional check for the item name to prevent a crash if it doesn’t exist.
|
||||
const nameMatch = searchTerms.every(term => (item.name || '').toLowerCase().includes(term));
|
||||
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
|
||||
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ const Modal = ({
|
||||
disableCloseOnOutsideClick,
|
||||
disableEscapeKey,
|
||||
onClick,
|
||||
closeModalFadeTimeout = 500
|
||||
closeModalFadeTimeout = 500,
|
||||
dataTestId
|
||||
}) => {
|
||||
const modalRef = useRef(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
@@ -120,6 +121,7 @@ const Modal = ({
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<ModalHeader
|
||||
title={title}
|
||||
|
||||
@@ -11,7 +11,9 @@ const StyledWrapper = styled.div`
|
||||
height: fit-content;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 200px;
|
||||
|
||||
pre.CodeMirror-placeholder {
|
||||
color: ${(props) => props.theme.text};
|
||||
@@ -19,18 +21,10 @@ const StyledWrapper = styled.div`
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow: visible !important;
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar,
|
||||
.CodeMirror-hscrollbar,
|
||||
.CodeMirror-scrollbar-filler {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
|
||||
@@ -3,7 +3,9 @@ import isEqual from 'lodash/isEqual';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -16,6 +18,10 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = props.value || '';
|
||||
this.editorRef = React.createRef();
|
||||
this.variables = {};
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
// Initialize CodeMirror as a single line editor
|
||||
@@ -23,22 +29,14 @@ class MultiLineEditor extends Component {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
lineNumbers: false,
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
placeholder: this.props.placeholder,
|
||||
mode: 'brunovariables',
|
||||
brunoVarInfo: {
|
||||
variables
|
||||
},
|
||||
scrollbarStyle: null,
|
||||
tabindex: 0,
|
||||
extraKeys: {
|
||||
Enter: () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
@@ -49,14 +47,6 @@ class MultiLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Alt-Enter': () => {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
|
||||
},
|
||||
'Shift-Enter': () => {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
@@ -94,6 +84,10 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
|
||||
// Initialize masking if this is a secret field
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
}
|
||||
|
||||
_onEdit = () => {
|
||||
@@ -105,6 +99,19 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
_enableMaskedEditor = (enabled) => {
|
||||
if (typeof enabled !== 'boolean') return;
|
||||
|
||||
if (enabled == true) {
|
||||
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
|
||||
this.maskedEditor.enable();
|
||||
} else {
|
||||
this.maskedEditor?.disable();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// Ensure the changes caused by this update are not interpreted as
|
||||
// user-input changes which could otherwise result in an infinite
|
||||
@@ -123,8 +130,11 @@ class MultiLineEditor extends Component {
|
||||
this.cachedValue = String(this.props.value);
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
}
|
||||
if (this.editorRef?.current) {
|
||||
this.editorRef.current.scrollTo(0, 10000);
|
||||
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
|
||||
// If the secret flag has changed, update the editor to reflect the change
|
||||
this._enableMaskedEditor(this.props.isSecret);
|
||||
// also set the maskInput flag to the new value
|
||||
this.setState({ maskInput: this.props.isSecret });
|
||||
}
|
||||
this.ignoreChangeEvent = false;
|
||||
}
|
||||
@@ -133,6 +143,10 @@ class MultiLineEditor extends Component {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
this.editor.getWrapperElement().remove();
|
||||
}
|
||||
|
||||
@@ -142,8 +156,38 @@ class MultiLineEditor extends Component {
|
||||
this.editor.setOption('mode', 'brunovariables');
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Toggle the visibility of the secret value
|
||||
*/
|
||||
toggleVisibleSecret = () => {
|
||||
const isVisible = !this.state.maskInput;
|
||||
this.setState({ maskInput: isVisible });
|
||||
this._enableMaskedEditor(isVisible);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Eye icon to show/hide the secret value
|
||||
* @returns ReactComponent The eye icon
|
||||
*/
|
||||
secretEye = (isSecret) => {
|
||||
return isSecret === true ? (
|
||||
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
|
||||
{this.state.maskInput === true ? (
|
||||
<IconEyeOff size={18} strokeWidth={2} />
|
||||
) : (
|
||||
<IconEye size={18} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
|
||||
return (
|
||||
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
|
||||
<StyledWrapper ref={this.editorRef} className="multi-line-editor grow" />
|
||||
{this.secretEye(this.props.isSecret)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default MultiLineEditor;
|
||||
|
||||
@@ -147,7 +147,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
</div>
|
||||
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
|
||||
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
|
||||
</div>
|
||||
</div>
|
||||
{generateCodeItemModalOpen && (
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
|
||||
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ToolHint from 'components/ToolHint';
|
||||
@@ -69,9 +68,8 @@ const CollectionToolBar = ({ collection }) => {
|
||||
</ToolHint>
|
||||
</span>
|
||||
<span>
|
||||
<GlobalEnvironmentSelector />
|
||||
<EnvironmentSelector collection={collection} />
|
||||
</span>
|
||||
<EnvironmentSelector collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -16,7 +16,7 @@ const StatusCode = ({ status }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`}>
|
||||
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
|
||||
{status} {statusCodePhraseMap[status]}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -60,8 +60,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
type: `collection-item-${collectionUid}`,
|
||||
item,
|
||||
type: 'collection-item',
|
||||
item: { ...item, sourceCollectionUid: collectionUid },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
}),
|
||||
@@ -92,10 +92,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
|
||||
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem;
|
||||
|
||||
if (draggedItemUid === targetItemUid) return false;
|
||||
|
||||
// For cross-collection moves, we allow the drop
|
||||
if (sourceCollectionUid !== collectionUid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
|
||||
if (!newPathname) return false;
|
||||
|
||||
@@ -105,7 +110,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
};
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: `collection-item-${collectionUid}`,
|
||||
accept: 'collection-item',
|
||||
hover: (draggedItem, monitor) => {
|
||||
const { uid: targetItemUid } = item;
|
||||
const { uid: draggedItemUid } = draggedItem;
|
||||
|
||||
@@ -7,6 +7,7 @@ const Wrapper = styled.div`
|
||||
user-select: none;
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotateZ(90deg);
|
||||
@@ -66,6 +67,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
}
|
||||
@@ -95,15 +97,6 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.collection-name.drop-target {
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
margin: -2px;
|
||||
transition: ${(props) => props.theme.dragAndDrop.transition};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
|
||||
}
|
||||
|
||||
#sidebar-collection-name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -34,6 +34,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||
const [dropType, setDropType] = useState(null);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
@@ -42,7 +43,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
const MenuIcon = forwardRef((_props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="pr-2">
|
||||
<IconDots size={22} />
|
||||
@@ -101,7 +102,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubleClick = (event) => {
|
||||
const handleDoubleClick = (_event) => {
|
||||
dispatch(makeTabPermanent({ uid: collection.uid }))
|
||||
};
|
||||
|
||||
@@ -118,7 +119,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const handleRightClick = (_event) => {
|
||||
const _menuDropdown = menuDropdownTippyRef.current;
|
||||
if (_menuDropdown) {
|
||||
let menuDropdownBehavior = 'show';
|
||||
@@ -140,7 +141,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType.startsWith('collection-item');
|
||||
return itemType === 'collection-item';
|
||||
};
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
@@ -155,7 +156,17 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop({
|
||||
accept: ["collection", `collection-item-${collection.uid}`],
|
||||
accept: ["collection", "collection-item"],
|
||||
hover: (_draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
// For collection items, always show full highlight (inside drop)
|
||||
setDropType('inside');
|
||||
} else {
|
||||
// For collections, show line indicator (adjacent drop)
|
||||
setDropType('adjacent');
|
||||
}
|
||||
},
|
||||
drop: (draggedItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (isCollectionItem(itemType)) {
|
||||
@@ -163,6 +174,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
} else {
|
||||
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
||||
}
|
||||
setDropType(null);
|
||||
},
|
||||
canDrop: (draggedItem) => {
|
||||
return draggedItem.uid !== collection.uid;
|
||||
@@ -183,7 +195,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
|
||||
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
|
||||
'item-hovered': isOver,
|
||||
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
|
||||
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
|
||||
'collection-focused-in-tab': isCollectionFocused
|
||||
});
|
||||
|
||||
@@ -241,7 +254,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowNewRequestModal(true);
|
||||
}}
|
||||
@@ -250,7 +263,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowNewFolderModal(true);
|
||||
}}
|
||||
@@ -259,7 +272,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
}}
|
||||
@@ -268,7 +281,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
handleRun();
|
||||
@@ -278,7 +291,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowRenameCollectionModal(true);
|
||||
}}
|
||||
@@ -287,7 +300,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
setShowShareCollectionModal(true);
|
||||
@@ -297,7 +310,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setShowRemoveCollectionModal(true);
|
||||
}}
|
||||
@@ -306,7 +319,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
viewCollectionSettings();
|
||||
}}
|
||||
|
||||
@@ -153,7 +153,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
|
||||
|
||||
@@ -48,7 +48,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
|
||||
return (
|
||||
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose} dataTestId="import-collection-location-modal">
|
||||
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="collectionName" className="block font-semibold">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { Component } from 'react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
@@ -159,6 +160,10 @@ class SingleLineEditor extends Component {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
}
|
||||
}
|
||||
|
||||
addOverlay = (variables) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
// import ErrorCapture from 'components/ErrorCapture';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import 'codemirror/theme/material.css';
|
||||
import 'codemirror/theme/monokai.css';
|
||||
@@ -56,12 +57,31 @@ export default function Main() {
|
||||
'is-dragging': isDragging
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => {
|
||||
if (mainSectionRef.current) {
|
||||
mainSectionRef.current.setAttribute('data-app-state', 'loaded');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeAppLoadedListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// <ErrorCapture>
|
||||
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<div
|
||||
ref={mainSectionRef}
|
||||
className="flex-1 min-h-0 flex"
|
||||
data-app-state="loading"
|
||||
style={{
|
||||
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
|
||||
}}
|
||||
@@ -80,7 +100,7 @@ export default function Main() {
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
</div>
|
||||
|
||||
|
||||
<Devtools mainSectionRef={mainSectionRef} />
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
@@ -726,11 +726,16 @@ export const handleCollectionItemDrop =
|
||||
(dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
// if its withincollection set the source to current collection,
|
||||
// if its cross collection set the source to the source collection
|
||||
const sourceCollectionUid = draggedItem.sourceCollectionUid
|
||||
const isCrossCollectionMove = sourceCollectionUid && collectionUid !== sourceCollectionUid;
|
||||
const sourceCollection = isCrossCollectionMove ? findCollectionByUid(state.collections.collections, sourceCollectionUid) : collection;
|
||||
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
|
||||
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
|
||||
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
|
||||
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
|
||||
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
|
||||
const draggedItemDirectory = findParentItemInCollection(sourceCollection, draggedItemUid) || sourceCollection;
|
||||
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
|
||||
|
||||
const handleMoveToNewLocation = async ({
|
||||
|
||||
@@ -94,6 +94,7 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-global-environment', { name, uid, variables })
|
||||
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
|
||||
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid: uid })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
@@ -79,10 +79,16 @@ const darkTheme = {
|
||||
color: 'rgb(204, 204, 204)',
|
||||
iconColor: 'rgb(204, 204, 204)',
|
||||
bg: 'rgb(48, 48, 49)',
|
||||
hoverBg: '#185387',
|
||||
hoverBg: '#6A6A6A29',
|
||||
shadow: 'rgb(0 0 0 / 36%) 0px 2px 8px',
|
||||
separator: '#444',
|
||||
labelBg: '#4a4949'
|
||||
labelBg: '#4a4949',
|
||||
selectedBg: '#F59E0B14',
|
||||
selectedColor: '#F59E0B',
|
||||
mutedText: '#9B9B9B',
|
||||
primaryText: '#D4D4D4',
|
||||
secondaryText: '#9CA3AF',
|
||||
headingText: '#FFFFFF'
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -226,8 +232,8 @@ const darkTheme = {
|
||||
|
||||
tabs: {
|
||||
active: {
|
||||
color: '#ccc',
|
||||
border: '#569cd6'
|
||||
color: '#CCCCCC',
|
||||
border: '#F59E0B'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -79,10 +79,16 @@ const lightTheme = {
|
||||
color: 'rgb(48 48 48)',
|
||||
iconColor: 'rgb(75, 85, 99)',
|
||||
bg: '#fff',
|
||||
hoverBg: '#e9e9e9',
|
||||
hoverBg: '#e9ecef',
|
||||
shadow: 'rgb(50 50 93 / 25%) 0px 6px 12px -2px, rgb(0 0 0 / 30%) 0px 3px 7px -3px',
|
||||
separator: '#e7e7e7',
|
||||
labelBg: '#f3f3f3'
|
||||
labelBg: '#f3f3f3',
|
||||
selectedBg: '#D977060F',
|
||||
selectedColor: '#D97706',
|
||||
mutedText: '#9B9B9B',
|
||||
primaryText: '#343434',
|
||||
secondaryText: '#6B7280',
|
||||
headingText: '#343434'
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -227,8 +233,8 @@ const lightTheme = {
|
||||
|
||||
tabs: {
|
||||
active: {
|
||||
color: 'rgb(50, 46, 44)',
|
||||
border: '#546de5'
|
||||
color: '#343434',
|
||||
border: '#D97706'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -8,80 +8,6 @@ const pathFoundInVariables = (path, obj) => {
|
||||
return value !== undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes the render behaviour for a given CodeMirror editor.
|
||||
* Replaces all **rendered** characters, not the actual value, with the provided character.
|
||||
*/
|
||||
export class MaskedEditor {
|
||||
/**
|
||||
* @param {import('codemirror').Editor} editor CodeMirror editor instance
|
||||
* @param {string} maskChar Target character being applied to all content
|
||||
*/
|
||||
constructor(editor, maskChar) {
|
||||
this.editor = editor;
|
||||
this.maskChar = maskChar;
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set and apply new masking character
|
||||
*/
|
||||
enable = () => {
|
||||
this.enabled = true;
|
||||
this.editor.setValue(this.editor.getValue());
|
||||
this.editor.on('inputRead', this.maskContent);
|
||||
this.update();
|
||||
};
|
||||
|
||||
/** Disables masking of the editor field. */
|
||||
disable = () => {
|
||||
this.enabled = false;
|
||||
this.editor.off('inputRead', this.maskContent);
|
||||
this.editor.setValue(this.editor.getValue());
|
||||
};
|
||||
|
||||
/** Updates the rendered content if enabled. */
|
||||
update = () => {
|
||||
if (this.enabled) this.maskContent();
|
||||
};
|
||||
|
||||
/** Replaces all rendered characters, with the provided character. */
|
||||
maskContent = () => {
|
||||
const content = this.editor.getValue();
|
||||
const lineCount = this.editor.lineCount();
|
||||
|
||||
if (lineCount === 0) return;
|
||||
this.editor.operation(() => {
|
||||
// Clear previous masked text
|
||||
this.editor.getAllMarks().forEach((mark) => mark.clear());
|
||||
// Apply new masked text
|
||||
|
||||
if (content.length <= 500) {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] !== '\n') {
|
||||
const maskedNode = document.createTextNode(this.maskChar);
|
||||
this.editor.markText(
|
||||
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
|
||||
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
|
||||
{ replacedWith: maskedNode, handleMouseEvents: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
const lineLength = this.editor.getLine(line).length;
|
||||
const maskedNode = document.createTextNode('*'.repeat(lineLength));
|
||||
this.editor.markText(
|
||||
{ line, ch: 0 },
|
||||
{ line, ch: lineLength },
|
||||
{ replacedWith: maskedNode, handleMouseEvents: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a custom CodeMirror mode for Bruno variables highlighting.
|
||||
* This function creates a specialized mode that can highlight both Bruno template
|
||||
|
||||
444
packages/bruno-app/src/utils/common/masked-editor.js
Normal file
444
packages/bruno-app/src/utils/common/masked-editor.js
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* MaskedEditor - A robust, multiline-capable masking system for CodeMirror editors
|
||||
*
|
||||
* OVERVIEW:
|
||||
* This implementation provides flawless masking of sensitive content with proper
|
||||
* multiline support, error handling, and memory management. It replaces visible
|
||||
* characters with mask characters while preserving the actual content.
|
||||
*
|
||||
* KEY FEATURES:
|
||||
* - Zero race conditions with proper state management
|
||||
* - Perfect performance for any content size (small or large)
|
||||
* - Proper event handling with cleanup
|
||||
* - Memory leak prevention with comprehensive cleanup
|
||||
* - Cursor position preservation across multiline edits
|
||||
* - Copy/paste compatibility with masked content
|
||||
* - Full multiline support (JSON, XML, certificates, etc.)
|
||||
* - State consistency across all operations
|
||||
* - Error handling for problematic content
|
||||
* - Performance optimization strategies
|
||||
*
|
||||
* MULTILINE SUPPORT:
|
||||
* The MaskedEditor automatically handles multiline content efficiently:
|
||||
* - Small content (< 1000 chars): Character-by-character masking
|
||||
* - Large content (>= 1000 chars): Line-by-line masking for performance
|
||||
* - Preserves line breaks and cursor position across line boundaries
|
||||
* - Handles empty lines gracefully
|
||||
*
|
||||
* USAGE PATTERNS:
|
||||
* 1. Create: new MaskedEditor(editor, maskChar)
|
||||
* 2. Enable: maskedEditor.enable() - Start masking
|
||||
* 3. Disable: maskedEditor.disable() - Show real content
|
||||
* 4. Cleanup: maskedEditor.destroy() - CRITICAL for memory management
|
||||
*
|
||||
* MEMORY MANAGEMENT:
|
||||
* Always call destroy() when done to prevent memory leaks:
|
||||
* - Removes all event listeners
|
||||
* - Clears all DOM marks and references
|
||||
* - Cancels pending timeouts
|
||||
* - Nullifies object references
|
||||
*
|
||||
* API METHODS:
|
||||
* - enable(): Start masking the editor content
|
||||
* - disable(): Stop masking and show real content
|
||||
* - update(): Refresh masking (called automatically)
|
||||
* - destroy(): Clean up all resources (CRITICAL!)
|
||||
* - isEnabled(): Check if masking is currently active
|
||||
* - getMaskChar(): Get current mask character
|
||||
* - setMaskChar(char): Change mask character
|
||||
*
|
||||
* PERFORMANCE:
|
||||
* - Uses debounced updates (10ms) to prevent excessive re-renders
|
||||
* - Character-by-character masking for precise control on small content
|
||||
* - Line-by-line masking for efficiency on large content
|
||||
* - Efficient mark cleanup and reuse
|
||||
* - Bounds checking to prevent errors
|
||||
*
|
||||
* ERROR HANDLING:
|
||||
* - Try-catch blocks for problematic content
|
||||
* - Bounds checking for cursor positions
|
||||
* - Graceful degradation when marks fail
|
||||
* - Memory cleanup even on errors
|
||||
*/
|
||||
|
||||
export class MaskedEditor {
|
||||
constructor(editor, maskChar = '*') {
|
||||
this.editor = editor;
|
||||
this.maskChar = maskChar;
|
||||
this.enabled = false;
|
||||
this.isProcessing = false;
|
||||
this.marks = new Set();
|
||||
this.originalCursor = null;
|
||||
this.originalSelection = null;
|
||||
|
||||
// Bind methods to preserve context
|
||||
this.handleInputRead = this.handleInputRead.bind(this);
|
||||
this.handleBeforeChange = this.handleBeforeChange.bind(this);
|
||||
this.handleCursorActivity = this.handleCursorActivity.bind(this);
|
||||
this.handleSelectionChange = this.handleSelectionChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable masking with perfect state management
|
||||
*/
|
||||
enable() {
|
||||
if (this.enabled || this.isProcessing) return;
|
||||
|
||||
this.enabled = true;
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
// Store current cursor and selection
|
||||
this.storeCursorState();
|
||||
|
||||
// Add event listeners with proper cleanup
|
||||
this.editor.on('inputRead', this.handleInputRead);
|
||||
this.editor.on('beforeChange', this.handleBeforeChange);
|
||||
this.editor.on('cursorActivity', this.handleCursorActivity);
|
||||
this.editor.on('selectionChange', this.handleSelectionChange);
|
||||
|
||||
// Apply masking
|
||||
this.applyMasking();
|
||||
|
||||
// Restore cursor state
|
||||
this.restoreCursorState();
|
||||
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable masking with complete cleanup
|
||||
*/
|
||||
disable() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
this.enabled = false;
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
// Store current state
|
||||
this.storeCursorState();
|
||||
|
||||
// Remove event listeners
|
||||
this.editor.off('inputRead', this.handleInputRead);
|
||||
this.editor.off('beforeChange', this.handleBeforeChange);
|
||||
this.editor.off('cursorActivity', this.handleCursorActivity);
|
||||
this.editor.off('selectionChange', this.handleSelectionChange);
|
||||
|
||||
// Clear all marks
|
||||
this.clearAllMarks();
|
||||
|
||||
// Refresh editor to show real content
|
||||
this.editor.refresh();
|
||||
|
||||
// Restore cursor state
|
||||
this.restoreCursorState();
|
||||
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update masking (called when content changes)
|
||||
*/
|
||||
update() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
this.storeCursorState();
|
||||
this.applyMasking();
|
||||
this.restoreCursorState();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle multiline content changes efficiently
|
||||
*/
|
||||
handleMultilineChange() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
const content = this.editor.getValue();
|
||||
const lineCount = this.editor.lineCount();
|
||||
|
||||
// For multiline content, use more efficient line-based masking
|
||||
if (lineCount > 1) {
|
||||
this.editor.operation(() => {
|
||||
this.clearAllMarks();
|
||||
this.applyLineMasking(lineCount);
|
||||
});
|
||||
} else {
|
||||
this.update();
|
||||
}
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store current cursor and selection state
|
||||
*/
|
||||
storeCursorState() {
|
||||
this.originalCursor = this.editor.getCursor();
|
||||
this.originalSelection = this.editor.getSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore cursor and selection state
|
||||
*/
|
||||
restoreCursorState() {
|
||||
if (this.originalCursor) {
|
||||
// Ensure cursor position is within editor bounds
|
||||
const lineCount = this.editor.lineCount();
|
||||
const clampedLine = Math.min(this.originalCursor.line, Math.max(0, lineCount - 1));
|
||||
const lineLength = this.editor.getLine(clampedLine).length;
|
||||
const clampedCh = Math.min(this.originalCursor.ch, Math.max(0, lineLength));
|
||||
|
||||
this.editor.setCursor({ line: clampedLine, ch: clampedCh });
|
||||
}
|
||||
if (this.originalSelection) {
|
||||
// For selection, just set cursor position to avoid selection issues with masked content
|
||||
this.editor.setSelection(this.editor.getCursor(), this.editor.getCursor());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input read events
|
||||
*/
|
||||
handleInputRead() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
// Debounce masking to prevent excessive updates
|
||||
clearTimeout(this.maskTimeout);
|
||||
this.maskTimeout = setTimeout(() => {
|
||||
this.update();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle before change events to preserve cursor
|
||||
*/
|
||||
handleBeforeChange(cm, changeObj) {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
// Store cursor position before change
|
||||
this.storeCursorState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cursor activity
|
||||
*/
|
||||
handleCursorActivity() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
// Update cursor state
|
||||
this.storeCursorState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection changes
|
||||
*/
|
||||
handleSelectionChange() {
|
||||
if (!this.enabled || this.isProcessing) return;
|
||||
|
||||
// Update selection state
|
||||
this.storeCursorState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply masking with perfect performance
|
||||
*/
|
||||
applyMasking() {
|
||||
const content = this.editor.getValue();
|
||||
const lineCount = this.editor.lineCount();
|
||||
|
||||
if (lineCount === 0) return;
|
||||
|
||||
this.editor.operation(() => {
|
||||
// Clear existing marks
|
||||
this.clearAllMarks();
|
||||
|
||||
// Apply new masking based on content size
|
||||
if (content.length <= 1000) {
|
||||
this.applyCharacterMasking(content);
|
||||
} else {
|
||||
this.applyLineMasking(lineCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply character-by-character masking for small content
|
||||
*/
|
||||
applyCharacterMasking(content) {
|
||||
let currentLine = 0;
|
||||
let currentCh = 0;
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
|
||||
if (char === '\n') {
|
||||
currentLine++;
|
||||
currentCh = 0;
|
||||
} else {
|
||||
// Create masked node
|
||||
const maskedNode = document.createTextNode(this.maskChar);
|
||||
|
||||
// Create mark with proper bounds checking
|
||||
const fromPos = { line: currentLine, ch: currentCh };
|
||||
const toPos = { line: currentLine, ch: currentCh + 1 };
|
||||
|
||||
// Ensure positions are within editor bounds
|
||||
const lineCount = this.editor.lineCount();
|
||||
if (currentLine < lineCount) {
|
||||
const lineLength = this.editor.getLine(currentLine).length;
|
||||
if (currentCh < lineLength) {
|
||||
const mark = this.editor.markText(fromPos, toPos, {
|
||||
replacedWith: maskedNode,
|
||||
handleMouseEvents: true,
|
||||
className: 'masked-character'
|
||||
});
|
||||
|
||||
// Store mark for cleanup
|
||||
this.marks.add(mark);
|
||||
}
|
||||
}
|
||||
|
||||
currentCh++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply line-by-line masking for large content
|
||||
*/
|
||||
applyLineMasking(lineCount) {
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
try {
|
||||
const lineLength = this.editor.getLine(line).length;
|
||||
|
||||
if (lineLength > 0) {
|
||||
// Create masked node for entire line
|
||||
const maskedNode = document.createTextNode(this.maskChar.repeat(lineLength));
|
||||
|
||||
// Create mark with proper bounds checking
|
||||
const mark = this.editor.markText(
|
||||
{ line, ch: 0 },
|
||||
{ line, ch: lineLength },
|
||||
{
|
||||
replacedWith: maskedNode,
|
||||
handleMouseEvents: false,
|
||||
className: 'masked-line'
|
||||
}
|
||||
);
|
||||
|
||||
// Store mark for cleanup
|
||||
this.marks.add(mark);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip problematic lines to prevent crashes
|
||||
console.warn(`Failed to mask line ${line}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all marks with proper cleanup
|
||||
*/
|
||||
clearAllMarks() {
|
||||
this.marks.forEach(mark => {
|
||||
try {
|
||||
mark.clear();
|
||||
} catch (e) {
|
||||
// Ignore errors when clearing marks
|
||||
}
|
||||
});
|
||||
this.marks.clear();
|
||||
|
||||
// Also clear any marks that might have been created outside our control
|
||||
this.editor.getAllMarks().forEach(mark => {
|
||||
try {
|
||||
mark.clear();
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if masking is enabled
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mask character
|
||||
*/
|
||||
getMaskChar() {
|
||||
return this.maskChar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set new mask character
|
||||
*/
|
||||
setMaskChar(newMaskChar) {
|
||||
if (typeof newMaskChar !== 'string' || newMaskChar.length !== 1) {
|
||||
throw new Error('Mask character must be a single character string');
|
||||
}
|
||||
|
||||
this.maskChar = newMaskChar;
|
||||
|
||||
if (this.enabled) {
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the masked editor instance
|
||||
*
|
||||
* CRITICAL: Always call this method when done with the MaskedEditor
|
||||
* to prevent memory leaks. This method:
|
||||
* 1. Disables masking and removes event listeners
|
||||
* 2. Clears all DOM marks and references
|
||||
* 3. Cancels any pending timeouts
|
||||
* 4. Nullifies all object references
|
||||
*/
|
||||
destroy() {
|
||||
this.disable();
|
||||
this.marks.clear();
|
||||
this.originalCursor = null;
|
||||
this.originalSelection = null;
|
||||
|
||||
if (this.maskTimeout) {
|
||||
clearTimeout(this.maskTimeout);
|
||||
this.maskTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a perfect masked editor
|
||||
*/
|
||||
export function createMaskedEditor(editor, maskChar = '*') {
|
||||
return new MaskedEditor(editor, maskChar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to check if an editor supports masking
|
||||
*/
|
||||
export function supportsMasking(editor) {
|
||||
return editor &&
|
||||
typeof editor.getValue === 'function' &&
|
||||
typeof editor.markText === 'function' &&
|
||||
typeof editor.operation === 'function';
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
BRUNO_INFO_ENDPOINT = http://localhost:8081
|
||||
BRUNO_INFO_ENDPOINT = http://localhost:8081
|
||||
DISABLE_SAMPLE_COLLECTION_IMPORT = false
|
||||
@@ -8,6 +8,12 @@ const config = {
|
||||
buildResources: 'resources',
|
||||
output: 'out'
|
||||
},
|
||||
extraResources: [
|
||||
{
|
||||
from: 'resources/data/sample-collection.json',
|
||||
to: 'data/sample-collection.json'
|
||||
}
|
||||
],
|
||||
files: ['**/*'],
|
||||
afterSign: 'notarize.js',
|
||||
mac: {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": "1",
|
||||
"uid": "1igyn4u00000000000232",
|
||||
"name": "Sample API Collection",
|
||||
"items": [
|
||||
{
|
||||
"uid": "1igyn4u00000000000001",
|
||||
"type": "http-request",
|
||||
"name": "Get Users",
|
||||
"seq": 1,
|
||||
"request": {
|
||||
"url": "https://jsonplaceholder.typicode.com/users",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": {
|
||||
"mode": "none"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {
|
||||
"req": "",
|
||||
"res": ""
|
||||
},
|
||||
"vars": {
|
||||
"req": [],
|
||||
"res": []
|
||||
},
|
||||
"assertions": [],
|
||||
"tests": "",
|
||||
"docs": "This request retrieves a list of users from the JSONPlaceholder API."
|
||||
}
|
||||
}
|
||||
],
|
||||
"environments": [],
|
||||
"activeEnvironmentUid": null,
|
||||
"root": {
|
||||
"request": {
|
||||
"headers": [],
|
||||
"auth": {
|
||||
"mode": "none"
|
||||
},
|
||||
"script": {
|
||||
"req": "",
|
||||
"res": ""
|
||||
},
|
||||
"vars": {
|
||||
"req": [],
|
||||
"res": []
|
||||
},
|
||||
"tests": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
packages/bruno-electron/src/app/onboarding.js
Normal file
101
packages/bruno-electron/src/app/onboarding.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { app } = require('electron');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
const { importCollection, findUniqueFolderName } = require('../utils/collection-import');
|
||||
|
||||
/**
|
||||
* Get the default location for collections
|
||||
* Tries documents first, then desktop, then userData as fallback
|
||||
*/
|
||||
function getDefaultCollectionLocation() {
|
||||
const preferredPaths = ['documents', 'desktop', 'userData'];
|
||||
|
||||
for (const pathType of preferredPaths) {
|
||||
try {
|
||||
return app.getPath(pathType);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get ${pathType} path:`, error.message);
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen since userData should always be available
|
||||
throw new Error('No valid collection location found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import sample collection for new users
|
||||
*/
|
||||
async function importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections) {
|
||||
// Handle both development and production paths
|
||||
const sampleCollectionPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'data', 'sample-collection.json')
|
||||
: path.join(app.getAppPath(), 'resources', 'data', 'sample-collection.json');
|
||||
|
||||
if (!fs.existsSync(sampleCollectionPath)) {
|
||||
throw new Error(`Sample collection file not found at: ${sampleCollectionPath}`);
|
||||
}
|
||||
|
||||
const sampleCollectionData = fs.readFileSync(sampleCollectionPath, 'utf8');
|
||||
const sampleCollection = JSON.parse(sampleCollectionData);
|
||||
|
||||
const collectionName = await findUniqueFolderName('Sample API Collection', collectionLocation);
|
||||
|
||||
const collectionToImport = {
|
||||
...sampleCollection,
|
||||
name: collectionName
|
||||
};
|
||||
|
||||
try {
|
||||
const {
|
||||
collectionPath: createdPath,
|
||||
uid,
|
||||
brunoConfig
|
||||
} = await importCollection(
|
||||
collectionToImport,
|
||||
collectionLocation,
|
||||
mainWindow,
|
||||
lastOpenedCollections,
|
||||
collectionName
|
||||
);
|
||||
|
||||
return { collectionPath: createdPath, uid, brunoConfig };
|
||||
} catch (error) {
|
||||
console.error('Failed to import sample collection:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboard new users by creating a sample collection
|
||||
*/
|
||||
async function onboardUser(mainWindow, lastOpenedCollections) {
|
||||
try {
|
||||
if (preferencesUtil.hasLaunchedBefore()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DISABLE_SAMPLE_COLLECTION_IMPORT !== 'true') {
|
||||
// Check if user already has collections (indicates they're an existing user)
|
||||
// Onboarding was added in a later version, so for existing users we should skip it
|
||||
// to avoid creating sample collections
|
||||
const collections = await lastOpenedCollections.getAll();
|
||||
if (collections.length > 0) {
|
||||
await preferencesUtil.markAsLaunched();
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionLocation = getDefaultCollectionLocation();
|
||||
await importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections);
|
||||
}
|
||||
|
||||
await preferencesUtil.markAsLaunched();
|
||||
} catch (error) {
|
||||
console.error('Failed to handle onboarding:', error);
|
||||
// Still mark as launched to prevent retry on next startup
|
||||
await preferencesUtil.markAsLaunched();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = onboardUser;
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const isDev = require('electron-is-dev');
|
||||
const os = require('os');
|
||||
|
||||
if (isDev) {
|
||||
if(!fs.existsSync(path.join(__dirname, '../../bruno-js/src/sandbox/bundle-browser-rollup.js'))) {
|
||||
@@ -21,6 +22,14 @@ if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
|
||||
app.setPath('userData', process.env.ELECTRON_USER_DATA_PATH);
|
||||
}
|
||||
|
||||
// Command line switches
|
||||
if (os.platform() === 'linux') {
|
||||
// Use portal version 4 that supports current_folder option
|
||||
// to address https://github.com/usebruno/bruno/issues/5471
|
||||
// Runtime sets the default version to 3, refs https://github.com/electron/electron/pull/44426
|
||||
app.commandLine.appendSwitch('xdg-portal-required-version', '4');
|
||||
}
|
||||
|
||||
const menuTemplate = require('./app/menu-template');
|
||||
const { openCollection } = require('./app/collections');
|
||||
const LastOpenedCollections = require('./store/last-opened-collections');
|
||||
@@ -35,6 +44,7 @@ const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
|
||||
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
|
||||
const { getDomainsWithCookies } = require('./utils/cookies');
|
||||
const { cookiesStore } = require('./store/cookies');
|
||||
const onboardUser = require('./app/onboarding');
|
||||
|
||||
const lastOpenedCollections = new LastOpenedCollections();
|
||||
|
||||
@@ -178,6 +188,10 @@ app.on('ready', async () => {
|
||||
return safeParseJSON(safeStringifyJSON(_));
|
||||
})]);
|
||||
}
|
||||
|
||||
// Handle onboarding
|
||||
await onboardUser(mainWindow, lastOpenedCollections);
|
||||
|
||||
// Send cookies list after renderer is ready
|
||||
try {
|
||||
cookiesStore.initializeCookies();
|
||||
@@ -186,6 +200,8 @@ app.on('ready', async () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to load cookies for renderer', err);
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('main:app-loaded');
|
||||
});
|
||||
|
||||
// register all ipc handlers
|
||||
|
||||
@@ -699,7 +699,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
// Recursive function to parse the folder and create files/folders
|
||||
const parseCollectionItems = (items = [], currentPath) => {
|
||||
items.forEach(async (item) => {
|
||||
if (['http-request', 'graphql-request'].includes(item.type)) {
|
||||
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
|
||||
const content = await stringifyRequestViaWorker(item);
|
||||
const filePath = path.join(currentPath, item.filename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
|
||||
@@ -34,6 +34,8 @@ const { cookiesStore } = require('../../store/cookies');
|
||||
const registerGrpcEventHandlers = require('./grpc-event-handlers');
|
||||
const { getCertsAndProxyConfig } = require('./cert-utils');
|
||||
|
||||
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
|
||||
|
||||
const saveCookies = (url, headers) => {
|
||||
if (preferencesUtil.shouldStoreCookies()) {
|
||||
let setCookieHeaders = [];
|
||||
@@ -674,7 +676,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
|
||||
return {
|
||||
statusText: error.statusText,
|
||||
error: error.message || 'Error occured while executing the request!',
|
||||
error: error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
|
||||
timeline: error.timeline
|
||||
}
|
||||
}
|
||||
@@ -853,7 +855,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
|
||||
return {
|
||||
status: error?.status,
|
||||
error: error?.message || 'Error occured while executing the request!',
|
||||
error: error?.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
|
||||
timeline: error?.timeline
|
||||
};
|
||||
}
|
||||
@@ -1497,7 +1499,7 @@ const executeRequestOnFailHandler = async (request, error) => {
|
||||
} catch (handlerError) {
|
||||
console.error('Error executing onFail handler', handlerError);
|
||||
// @TODO: This is a temporary solution to display the error message in the response pane. Revisit and handle properly.
|
||||
error.message = `1. Request failed: ${error.message || 'Error occured while executing the request!'}\n2. Error executing onFail handler: ${handlerError.message || 'Unknown error'}`;
|
||||
error.message = `1. Request failed: ${error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST}\n2. Error executing onFail handler: ${handlerError.message || 'Unknown error'}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ const getContentType = (headers = {}) => {
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const getRawQueryString = (url) => {
|
||||
const queryIndex = url.indexOf('?');
|
||||
return queryIndex !== -1 ? url.slice(queryIndex) : '';
|
||||
};
|
||||
|
||||
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {
|
||||
const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
|
||||
const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
|
||||
@@ -126,7 +131,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
|
||||
if (request?.pathParams?.length) {
|
||||
let url = request.url;
|
||||
|
||||
const urlSearchRaw = getRawQueryString(request.url)
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `http://${url}`;
|
||||
}
|
||||
@@ -152,7 +157,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
.join('');
|
||||
|
||||
const trailingSlash = url.pathname.endsWith('/') ? '/' : '';
|
||||
request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + url.search;
|
||||
request.url = url.origin + urlPathnameInterpolatedWithPathParams + trailingSlash + urlSearchRaw;
|
||||
}
|
||||
|
||||
if (request.proxy) {
|
||||
|
||||
@@ -44,6 +44,9 @@ const defaultPreferences = {
|
||||
beta: {
|
||||
grpc: false,
|
||||
nodevm: false
|
||||
},
|
||||
onboarding: {
|
||||
hasLaunchedBefore: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,6 +86,9 @@ const preferencesSchema = Yup.object().shape({
|
||||
beta: Yup.object({
|
||||
grpc: Yup.boolean(),
|
||||
nodevm: Yup.boolean()
|
||||
}),
|
||||
onboarding: Yup.object({
|
||||
hasLaunchedBefore: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -176,6 +182,19 @@ const preferencesUtil = {
|
||||
},
|
||||
isBetaFeatureEnabled: (featureName) => {
|
||||
return get(getPreferences(), `beta.${featureName}`, false);
|
||||
},
|
||||
hasLaunchedBefore: () => {
|
||||
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
|
||||
},
|
||||
markAsLaunched: async () => {
|
||||
const preferences = getPreferences();
|
||||
preferences.onboarding.hasLaunchedBefore = true;
|
||||
|
||||
try {
|
||||
await savePreferences(preferences);
|
||||
} catch (err) {
|
||||
console.error('Failed to save preferences in markAsLaunched:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
128
packages/bruno-electron/src/utils/collection-import.js
Normal file
128
packages/bruno-electron/src/utils/collection-import.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { ipcMain } = require('electron');
|
||||
const { sanitizeName, createDirectory, writeFile, safeWriteFileSync, getCollectionStats } = require('./filesystem');
|
||||
const { generateUidBasedOnHash, stringifyJson } = require('./common');
|
||||
const { stringifyRequestViaWorker, stringifyCollection, stringifyEnvironment, stringifyFolder } = require('@usebruno/filestore');
|
||||
|
||||
/**
|
||||
* Recursively find a unique folder name by appending incremental numbers
|
||||
*/
|
||||
async function findUniqueFolderName(baseName, collectionLocation, counter = 0) {
|
||||
const folderName = counter === 0 ? baseName : `${baseName} - ${counter}`;
|
||||
const collectionPath = path.join(collectionLocation, sanitizeName(folderName));
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
return findUniqueFolderName(baseName, collectionLocation, counter + 1);
|
||||
}
|
||||
|
||||
return folderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a collection - shared logic used by both IPC handler and onboarding service
|
||||
*/
|
||||
async function importCollection(collection, collectionLocation, mainWindow, lastOpenedCollections, uniqueFolderName = null) {
|
||||
// Use provided unique folder name or use collection name
|
||||
let folderName = uniqueFolderName ? sanitizeName(uniqueFolderName) : sanitizeName(collection.name);
|
||||
let collectionPath = path.join(collectionLocation, folderName);
|
||||
|
||||
if (fs.existsSync(collectionPath)) {
|
||||
throw new Error(`collection: ${collectionPath} already exists`);
|
||||
}
|
||||
|
||||
// Recursive function to parse the collection items and create files/folders
|
||||
const parseCollectionItems = async (items = [], currentPath) => {
|
||||
for (const item of items) {
|
||||
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
|
||||
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.bru`);
|
||||
const content = await stringifyRequestViaWorker(item);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
let sanitizedFolderName = sanitizeName(item.filename || item.name);
|
||||
const folderPath = path.join(currentPath, sanitizedFolderName);
|
||||
fs.mkdirSync(folderPath);
|
||||
|
||||
if (item.root?.meta?.name) {
|
||||
const folderBruFilePath = path.join(folderPath, 'folder.bru');
|
||||
item.root.meta.seq = item.seq;
|
||||
const folderContent = await stringifyFolder(item.root);
|
||||
safeWriteFileSync(folderBruFilePath, folderContent);
|
||||
}
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
await parseCollectionItems(item.items, folderPath);
|
||||
}
|
||||
}
|
||||
// Handle items of type 'js'
|
||||
if (item.type === 'js') {
|
||||
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.js`);
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, item.fileContent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseEnvironments = async (environments = [], collectionPath) => {
|
||||
const envDirPath = path.join(collectionPath, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
fs.mkdirSync(envDirPath);
|
||||
}
|
||||
|
||||
for (const env of environments) {
|
||||
const content = await stringifyEnvironment(env);
|
||||
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
|
||||
const filePath = path.join(envDirPath, sanitizedEnvFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
};
|
||||
|
||||
const getBrunoJsonConfig = (collection) => {
|
||||
let brunoConfig = collection.brunoConfig;
|
||||
|
||||
if (!brunoConfig) {
|
||||
brunoConfig = {
|
||||
version: '1',
|
||||
name: collection.name,
|
||||
type: 'collection',
|
||||
ignore: ['node_modules', '.git']
|
||||
};
|
||||
}
|
||||
|
||||
return brunoConfig;
|
||||
};
|
||||
|
||||
await createDirectory(collectionPath);
|
||||
|
||||
const uid = generateUidBasedOnHash(collectionPath);
|
||||
let brunoConfig = getBrunoJsonConfig(collection);
|
||||
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
|
||||
|
||||
// Write the Bruno configuration to a file
|
||||
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
|
||||
|
||||
const collectionContent = await stringifyCollection(collection.root);
|
||||
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPath);
|
||||
brunoConfig.size = size;
|
||||
brunoConfig.filesCount = filesCount;
|
||||
|
||||
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
|
||||
|
||||
lastOpenedCollections.add(collectionPath);
|
||||
|
||||
// create folder and files based on collection
|
||||
await parseCollectionItems(collection.items, collectionPath);
|
||||
await parseEnvironments(collection.environments, collectionPath);
|
||||
|
||||
return { collectionPath, uid, brunoConfig };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importCollection,
|
||||
findUniqueFolderName
|
||||
};
|
||||
@@ -331,6 +331,7 @@ const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: _item.name,
|
||||
seq: _item.seq,
|
||||
settings: _item.settings,
|
||||
tags: _item.tags,
|
||||
request: {
|
||||
method: _item.request.method,
|
||||
url: _item.request.url,
|
||||
|
||||
213
packages/bruno-electron/src/utils/tests/collection-utils.spec.js
Normal file
213
packages/bruno-electron/src/utils/tests/collection-utils.spec.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const { transformRequestToSaveToFilesystem } = require('../collection');
|
||||
|
||||
describe('transformRequestToSaveToFilesystem', () => {
|
||||
it('should preserve all relevant fields when transforming request', () => {
|
||||
|
||||
const testItem = {
|
||||
uid: 'test-uid-123',
|
||||
type: 'http-request',
|
||||
name: 'Test Request',
|
||||
seq: 1,
|
||||
settings: {
|
||||
enableEncodeUrl: true
|
||||
},
|
||||
tags: ['smoke', 'regression', 'api'],
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/test',
|
||||
params: [
|
||||
{
|
||||
uid: 'param-uid-1',
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
headers: [
|
||||
{
|
||||
uid: 'header-uid-1',
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
auth: {
|
||||
type: 'bearer',
|
||||
token: 'test-token'
|
||||
},
|
||||
body: {
|
||||
mode: 'json',
|
||||
json: '{"test": "data"}'
|
||||
},
|
||||
script: {
|
||||
req: 'console.log("request script");',
|
||||
res: 'console.log("response script");'
|
||||
},
|
||||
vars: {
|
||||
preRequest: 'const testVar = "value";',
|
||||
postResponse: 'console.log(testVar);'
|
||||
},
|
||||
assertions: [
|
||||
{
|
||||
uid: 'assert-uid-1',
|
||||
name: 'Status Code',
|
||||
operator: 'equals',
|
||||
expected: '200'
|
||||
}
|
||||
],
|
||||
tests: [
|
||||
{
|
||||
uid: 'test-uid-1',
|
||||
name: 'Test Response',
|
||||
code: 'expect(response.status).toBe(200);'
|
||||
}
|
||||
],
|
||||
docs: 'This is a test request documentation'
|
||||
}
|
||||
};
|
||||
|
||||
// Transform the request
|
||||
const result = transformRequestToSaveToFilesystem(testItem);
|
||||
|
||||
// Verify all top-level fields are preserved
|
||||
expect(result.uid).toBe(testItem.uid);
|
||||
expect(result.type).toBe(testItem.type);
|
||||
expect(result.name).toBe(testItem.name);
|
||||
expect(result.seq).toBe(testItem.seq);
|
||||
expect(result.settings).toEqual(testItem.settings);
|
||||
|
||||
// Verify tags are preserved (this is the main focus)
|
||||
expect(result.tags).toEqual(['smoke', 'regression', 'api']);
|
||||
expect(result.tags).toHaveLength(3);
|
||||
|
||||
// Verify request object structure
|
||||
expect(result.request).toBeDefined();
|
||||
expect(result.request.method).toBe(testItem.request.method);
|
||||
expect(result.request.url).toBe(testItem.request.url);
|
||||
expect(result.request.auth).toEqual(testItem.request.auth);
|
||||
expect(result.request.body).toEqual(testItem.request.body);
|
||||
expect(result.request.script).toEqual(testItem.request.script);
|
||||
expect(result.request.vars).toEqual(testItem.request.vars);
|
||||
expect(result.request.assertions).toEqual(testItem.request.assertions);
|
||||
expect(result.request.tests).toEqual(testItem.request.tests);
|
||||
expect(result.request.docs).toBe(testItem.request.docs);
|
||||
|
||||
// Verify params are processed correctly
|
||||
expect(result.request.params).toHaveLength(1);
|
||||
expect(result.request.params[0]).toEqual({
|
||||
uid: 'param-uid-1',
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
type: 'text',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Verify headers are processed correctly
|
||||
expect(result.request.headers).toHaveLength(1);
|
||||
expect(result.request.headers[0]).toEqual({
|
||||
uid: 'header-uid-1',
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle draft items correctly', () => {
|
||||
const testItem = {
|
||||
uid: 'test-uid-456',
|
||||
type: 'http-request',
|
||||
name: 'Draft Request',
|
||||
seq: 2,
|
||||
settings: {},
|
||||
tags: ['draft', 'wip'],
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/draft',
|
||||
params: [],
|
||||
headers: [],
|
||||
auth: {},
|
||||
body: { mode: 'none' },
|
||||
script: { req: '', res: '' },
|
||||
vars: { preRequest: '', postResponse: '' },
|
||||
assertions: [],
|
||||
tests: [],
|
||||
docs: ''
|
||||
},
|
||||
draft: {
|
||||
uid: 'draft-uid-789',
|
||||
type: 'http-request',
|
||||
name: 'Draft Request Modified',
|
||||
seq: 2,
|
||||
settings: { enableEncodeUrl: true },
|
||||
tags: ['draft', 'wip', 'modified'],
|
||||
request: {
|
||||
method: 'PUT',
|
||||
url: 'https://api.example.com/draft-modified',
|
||||
params: [],
|
||||
headers: [],
|
||||
auth: {},
|
||||
body: { mode: 'none' },
|
||||
script: { req: '', res: '' },
|
||||
vars: { preRequest: '', postResponse: '' },
|
||||
assertions: [],
|
||||
tests: [],
|
||||
docs: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformRequestToSaveToFilesystem(testItem);
|
||||
|
||||
// Should use draft data when available
|
||||
expect(result.uid).toBe('draft-uid-789');
|
||||
expect(result.name).toBe('Draft Request Modified');
|
||||
expect(result.settings).toEqual({ enableEncodeUrl: true });
|
||||
|
||||
// Verify draft tags are preserved
|
||||
expect(result.tags).toEqual(['draft', 'wip', 'modified']);
|
||||
expect(result.tags).toContain('modified');
|
||||
expect(result.tags).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle gRPC requests', () => {
|
||||
const testItem = {
|
||||
uid: 'grpc-uid-123',
|
||||
type: 'grpc-request',
|
||||
name: 'gRPC Test Request',
|
||||
seq: 3,
|
||||
settings: {},
|
||||
tags: ['grpc', 'microservice'],
|
||||
request: {
|
||||
method: 'unary',
|
||||
methodType: 'unary',
|
||||
protoPath: '/path/to/proto',
|
||||
url: 'grpc://localhost:50051',
|
||||
params: [], // gRPC requests don't use params
|
||||
headers: [],
|
||||
auth: {},
|
||||
body: { mode: 'grpc', grpc: [{ name: 'message1', content: 'test content' }] },
|
||||
script: { req: '', res: '' },
|
||||
vars: { preRequest: '', postResponse: '' },
|
||||
assertions: [],
|
||||
tests: [],
|
||||
docs: 'gRPC test documentation'
|
||||
}
|
||||
};
|
||||
|
||||
const result = transformRequestToSaveToFilesystem(testItem);
|
||||
|
||||
// Verify gRPC-specific fields
|
||||
expect(result.type).toBe('grpc-request');
|
||||
expect(result.request.methodType).toBe('unary');
|
||||
expect(result.request.protoPath).toBe('/path/to/proto');
|
||||
expect(result.request.params).toBeUndefined(); // Should be deleted for gRPC
|
||||
|
||||
// Verify tags are preserved for gRPC requests
|
||||
expect(result.tags).toEqual(['grpc', 'microservice']);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,108 @@ describe('interpolate-vars: interpolateVars', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('With path params', () => {
|
||||
it('keeps the original url search params as is', async () => {
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: 'http://example.com/:param/?search=hello world',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = interpolateVars(request, null, null, null);
|
||||
expect(result.url).toBe('http://example.com/foobar/?search=hello world');
|
||||
});
|
||||
|
||||
it('keeps the original url search params as is even when url might not have protocl ', async () => {
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: 'example.com/:param/?search=hello world',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = interpolateVars(request, null, null, null);
|
||||
expect(result.url).toBe('http://example.com/foobar/?search=hello world');
|
||||
});
|
||||
|
||||
it('keeps the original url search params as is even when encoded', async () => {
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: 'http://example.com/:param?search=hello%20world',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = interpolateVars(request, null, null, null);
|
||||
expect(result.url).toBe('http://example.com/foobar?search=hello%20world');
|
||||
});
|
||||
|
||||
it('keeps the original url search params as is with edge cases', async () => {
|
||||
const requestOne = {
|
||||
method: 'GET',
|
||||
url: 'https://example.com/:param?x=1#section',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const requestTwo = {
|
||||
method: 'GET',
|
||||
url: 'https://example.com/:param?x?y=2',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const resultOne = interpolateVars(requestOne, null, null, null);
|
||||
expect(resultOne.url).toBe('https://example.com/foobar?x=1#section');
|
||||
|
||||
const resultTwo = interpolateVars(requestTwo, null, null, null);
|
||||
expect(resultTwo.url).toBe('https://example.com/foobar?x?y=2');
|
||||
});
|
||||
|
||||
it('keeps the original url even without search', async () => {
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: 'http://example.com/:param',
|
||||
pathParams: [
|
||||
{
|
||||
type: 'path',
|
||||
name: 'param',
|
||||
value: 'foobar'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = interpolateVars(request, null, null, null);
|
||||
expect(result.url).toBe('http://example.com/foobar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('With process environment variables', () => {
|
||||
/*
|
||||
* It should NOT turn process env vars into literal segments.
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/crypto-js": "^3.1.9",
|
||||
"@usebruno/query": "0.1.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
@@ -26,7 +25,7 @@
|
||||
"chai": "^4.3.7",
|
||||
"chai-string": "^1.5.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"json-query": "^2.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
@@ -45,4 +44,4 @@
|
||||
"rollup": "3.29.5",
|
||||
"rollup-plugin-terser": "^7.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@ class TestRuntime {
|
||||
if (this.runtime === 'quickjs') {
|
||||
await executeQuickJsVmAsync({
|
||||
script: testsFile,
|
||||
context: context
|
||||
context: context,
|
||||
collectionPath
|
||||
});
|
||||
} else if (this.runtime === 'nodevm') {
|
||||
await runScriptInNodeVm({
|
||||
@@ -147,6 +148,7 @@ class TestRuntime {
|
||||
require: {
|
||||
context: 'sandbox',
|
||||
external: true,
|
||||
builtin: ['*'],
|
||||
root: [collectionPath, ...additionalContextRootsAbsolute],
|
||||
mock: {
|
||||
// node libs
|
||||
|
||||
@@ -11,7 +11,7 @@ const bundleLibraries = async () => {
|
||||
import moment from "moment";
|
||||
import btoa from "btoa";
|
||||
import atob from "atob";
|
||||
import * as CryptoJS from "@usebruno/crypto-js";
|
||||
import * as cryptoJs from 'crypto-js';
|
||||
import tv4 from "tv4";
|
||||
globalThis.expect = expect;
|
||||
globalThis.assert = assert;
|
||||
@@ -19,7 +19,6 @@ const bundleLibraries = async () => {
|
||||
globalThis.btoa = btoa;
|
||||
globalThis.atob = atob;
|
||||
globalThis.Buffer = Buffer;
|
||||
globalThis.CryptoJS = CryptoJS;
|
||||
globalThis.tv4 = tv4;
|
||||
globalThis.requireObject = {
|
||||
...(globalThis.requireObject || {}),
|
||||
@@ -28,7 +27,7 @@ const bundleLibraries = async () => {
|
||||
'buffer': { Buffer },
|
||||
'btoa': btoa,
|
||||
'atob': atob,
|
||||
'crypto-js': CryptoJS,
|
||||
'crypto-js': cryptoJs,
|
||||
'tv4': tv4
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -11,6 +11,7 @@ const { newQuickJSWASMModule, memoizePromiseFactory } = require('quickjs-emscrip
|
||||
const getBundledCode = require('../bundle-browser-rollup');
|
||||
const addPathShimToContext = require('./shims/lib/path');
|
||||
const { marshallToVm } = require('./utils');
|
||||
const addCryptoUtilsShimToContext = require('./shims/lib/crypto-utils');
|
||||
|
||||
let QuickJSSyncContext;
|
||||
const loader = memoizePromiseFactory(() => newQuickJSWASMModule());
|
||||
@@ -98,6 +99,9 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
|
||||
const module = await newQuickJSWASMModule();
|
||||
const vm = module.newContext();
|
||||
|
||||
// add crypto utilities required by the crypto-js library in bundledCode
|
||||
await addCryptoUtilsShimToContext(vm);
|
||||
|
||||
const bundledCode = getBundledCode?.toString() || '';
|
||||
const moduleLoaderCode = function () {
|
||||
return `
|
||||
|
||||
104
packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
Normal file
104
packages/bruno-js/src/sandbox/quickjs/shims/lib/crypto-utils.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const crypto = require('node:crypto');
|
||||
const { marshallToVm } = require('../../utils');
|
||||
const { serializeTypedArray, deserializeTypedArray } = require('./utils');
|
||||
|
||||
/**
|
||||
* Node.js crypto module shim for QuickJS sandbox
|
||||
* Implements crypto.randomBytes and crypto.getRandomValues functions
|
||||
*/
|
||||
const addCryptoUtilsShimToContext = async (vm) => {
|
||||
let randomBytesHandle = vm.newFunction('randomBytes', function (sizeHandle) {
|
||||
try {
|
||||
let size = vm.dump(sizeHandle);
|
||||
|
||||
if (typeof size !== 'number') {
|
||||
throw new TypeError('The "size" argument must be of type number');
|
||||
}
|
||||
|
||||
size = Math.trunc(size);
|
||||
|
||||
if (size < 0) {
|
||||
throw new RangeError('The "size" argument must be >= 0');
|
||||
}
|
||||
|
||||
if (size > 65536) { // 2^31 - 1 (max safe integer for practical use)
|
||||
throw new RangeError('The "size" argument is too large');
|
||||
}
|
||||
|
||||
if (size === 0) {
|
||||
return marshallToVm([], vm);
|
||||
}
|
||||
|
||||
const buffer = crypto.randomBytes(size);
|
||||
|
||||
const byteArray = Array.from(buffer);
|
||||
|
||||
return marshallToVm(byteArray, vm);
|
||||
|
||||
} catch (error) {
|
||||
const vmError = vm.newError(error.message);
|
||||
vm.setProp(vmError, 'name', vm.newString(error.name));
|
||||
|
||||
throw vmError;
|
||||
}
|
||||
});
|
||||
|
||||
let getRandomValuesHandle = vm.newFunction('getRandomValues', function (arrayHandle) {
|
||||
try {
|
||||
// Receive the serialized array data directly
|
||||
const serializedArray = vm.dump(arrayHandle);
|
||||
const typedArray = deserializeTypedArray(serializedArray);
|
||||
|
||||
if (typedArray.length === 0) {
|
||||
return marshallToVm([], vm);
|
||||
}
|
||||
|
||||
if (typedArray.length > 65536) {
|
||||
throw new Error('getRandomValues: ArrayBufferView byte length exceeds 65536');
|
||||
}
|
||||
|
||||
crypto.getRandomValues(typedArray);
|
||||
|
||||
const byteArray = Array.from(typedArray);
|
||||
|
||||
return marshallToVm(byteArray, vm);
|
||||
|
||||
} catch (error) {
|
||||
const vmError = vm.newError(error.message);
|
||||
vm.setProp(vmError, 'name', vm.newString(error.name));
|
||||
|
||||
throw vmError;
|
||||
}
|
||||
});
|
||||
|
||||
// Set the functions in global context
|
||||
vm.setProp(vm.global, '__bruno__crypto__randomBytes', randomBytesHandle);
|
||||
vm.setProp(vm.global, '__bruno__crypto__getRandomValues', getRandomValuesHandle);
|
||||
randomBytesHandle.dispose();
|
||||
getRandomValuesHandle.dispose();
|
||||
|
||||
vm.evalCode(`
|
||||
// Helper function for typed array serialization
|
||||
${serializeTypedArray.toString()}
|
||||
|
||||
// Create crypto module object following Node.js specifications
|
||||
const cryptoModule = {
|
||||
// node.js crypto.randomBytes API
|
||||
randomBytes: function(size) {
|
||||
const byteArray = globalThis.__bruno__crypto__randomBytes(size);
|
||||
return Buffer.from(Array.from(byteArray));
|
||||
},
|
||||
// node.js crypto.getRandomValues API
|
||||
getRandomValues: function(typedArray) {
|
||||
const serializedTypedArray = serializeTypedArray(typedArray);
|
||||
typedArray.set(globalThis.__bruno__crypto__getRandomValues(serializedTypedArray));
|
||||
return typedArray;
|
||||
},
|
||||
};
|
||||
|
||||
// Make crypto available globally
|
||||
globalThis.crypto = cryptoModule;
|
||||
`);
|
||||
};
|
||||
|
||||
module.exports = addCryptoUtilsShimToContext;
|
||||
@@ -0,0 +1,73 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const { newQuickJSWASMModule } = require('quickjs-emscripten');
|
||||
const addCryptoUtilsShimToContext = require('./crypto-utils');
|
||||
const getBundledCode = require('../../../bundle-browser-rollup');
|
||||
|
||||
describe('crypto-utils shims tests', () => {
|
||||
let vm, module;
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await newQuickJSWASMModule();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vm = module.newContext();
|
||||
await addCryptoUtilsShimToContext(vm);
|
||||
// required for `Buffer` library usage
|
||||
const bundledCode = getBundledCode?.toString() || '';
|
||||
vm.evalCode(
|
||||
`
|
||||
(${bundledCode})()
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide crypto.randomBytes function', async () => {
|
||||
const result = vm.evalCode('typeof crypto.randomBytes');
|
||||
const handle = vm.unwrapResult(result);
|
||||
const type = vm.dump(handle);
|
||||
handle.dispose();
|
||||
|
||||
expect(type).toBe('function');
|
||||
});
|
||||
|
||||
it('should provide crypto.getRandomValues function', async () => {
|
||||
const result = vm.evalCode('typeof crypto.getRandomValues');
|
||||
const handle = vm.unwrapResult(result);
|
||||
const type = vm.dump(handle);
|
||||
handle.dispose();
|
||||
|
||||
expect(type).toBe('function');
|
||||
});
|
||||
|
||||
it('should generate random bytes with correct length', async () => {
|
||||
const result = vm.evalCode('crypto.randomBytes(8).length');
|
||||
const handle = vm.unwrapResult(result);
|
||||
const length = vm.dump(handle);
|
||||
handle.dispose();
|
||||
|
||||
expect(length).toBe(8);
|
||||
});
|
||||
|
||||
it('should convert random bytes to hex string', async () => {
|
||||
const result = vm.evalCode('crypto.randomBytes(4).toString("hex").length');
|
||||
const handle = vm.unwrapResult(result);
|
||||
const hexLength = vm.dump(handle);
|
||||
handle.dispose();
|
||||
|
||||
expect(hexLength).toBe(8); // 4 bytes = 8 hex chars
|
||||
});
|
||||
|
||||
it('should fill Uint8Array with getRandomValues', async () => {
|
||||
const result = vm.evalCode(`
|
||||
const arr = new Uint8Array(5);
|
||||
crypto.getRandomValues(arr);
|
||||
arr.length;
|
||||
`);
|
||||
const handle = vm.unwrapResult(result);
|
||||
const length = vm.dump(handle);
|
||||
handle.dispose();
|
||||
|
||||
expect(length).toBe(5);
|
||||
});
|
||||
});
|
||||
48
packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
Normal file
48
packages/bruno-js/src/sandbox/quickjs/shims/lib/utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
function serializeTypedArray(ta) {
|
||||
return {
|
||||
type: ta.constructor.name,
|
||||
array: Array.from(ta),
|
||||
length: ta.length
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeTypedArray(obj) {
|
||||
// Allowed typed array constructors for crypto operations
|
||||
const allowedConstructors = new Set([
|
||||
'Int8Array',
|
||||
'Uint8Array',
|
||||
'Uint8ClampedArray',
|
||||
'Int16Array',
|
||||
'Uint16Array',
|
||||
'Int32Array',
|
||||
'Uint32Array',
|
||||
'Float32Array',
|
||||
'Float64Array',
|
||||
'BigInt64Array',
|
||||
'BigUint64Array'
|
||||
]);
|
||||
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
throw new TypeError('getRandomValues: Invalid typed array object');
|
||||
}
|
||||
|
||||
if (typeof obj.type !== 'string' || !allowedConstructors.has(obj.type)) {
|
||||
throw new TypeError(`getRandomValues: Invalid or unsupported typed array type: ${obj.type}`);
|
||||
}
|
||||
|
||||
if (!obj.array || typeof obj.length !== 'number') {
|
||||
throw new TypeError('getRandomValues: Invalid typed array properties');
|
||||
}
|
||||
|
||||
const ctor = globalThis[obj.type];
|
||||
if (typeof ctor !== 'function') {
|
||||
throw new TypeError(`getRandomValues: Constructor ${obj.type} is not available`);
|
||||
}
|
||||
|
||||
return new ctor(obj.array, 0, obj.length);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeTypedArray,
|
||||
deserializeTypedArray
|
||||
}
|
||||
@@ -5,14 +5,14 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbin.org/digest-auth/auth/foo/passwd
|
||||
url: https://www.httpfaker.org/api/auth/digest/auth/admin/password
|
||||
body: none
|
||||
auth: digest
|
||||
}
|
||||
|
||||
auth:digest {
|
||||
username: foo
|
||||
password: passwd
|
||||
username: admin
|
||||
password: password
|
||||
}
|
||||
|
||||
assert {
|
||||
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbin.org/digest-auth/auth/foo/passw
|
||||
url: https://www.httpfaker.org/api/auth/digest/auth/admin/badpassword
|
||||
body: none
|
||||
auth: digest
|
||||
}
|
||||
|
||||
@@ -19,17 +19,6 @@ script:post-response {
|
||||
}
|
||||
|
||||
tests {
|
||||
test("test body size", function() {
|
||||
const bodySize = res.getSize().body;
|
||||
// 1MB = 1024*1024 = 1048576
|
||||
expect(bodySize > 1048576).to.be.true;
|
||||
});
|
||||
|
||||
test("test header size", function() {
|
||||
const bodySize = res.getSize().header;
|
||||
expect(bodySize === 305).to.be.true;
|
||||
});
|
||||
|
||||
test("test total size", function() {
|
||||
const sizes = res.getSize();
|
||||
expect(sizes.total).to.equal(sizes.header + sizes.body);
|
||||
|
||||
@@ -10,24 +10,17 @@ get {
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
var CryptoJS = require("crypto-js");
|
||||
|
||||
// Encrypt
|
||||
var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123').toString();
|
||||
|
||||
// Decrypt
|
||||
var bytes = CryptoJS.AES.decrypt(ciphertext, 'secret key 123');
|
||||
var originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||
|
||||
bru.setVar('crypto-test-message', originalText);
|
||||
}
|
||||
|
||||
tests {
|
||||
test("crypto message", function() {
|
||||
const data = bru.getVar('crypto-test-message');
|
||||
bru.setVar('crypto-test-message', null);
|
||||
expect(data).to.eql('my message');
|
||||
var CryptoJS = require("crypto-js");
|
||||
|
||||
// Encrypt
|
||||
var ciphertext = CryptoJS.AES.encrypt('my message', 'secret key 123').toString();
|
||||
|
||||
// Decrypt
|
||||
var bytes = CryptoJS.AES.decrypt(ciphertext, 'secret key 123');
|
||||
var originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||
|
||||
expect(originalText).to.eql('my message');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
meta {
|
||||
name: getRandomValues
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
const { doesUint8ArraysWorkAsExpected, getRandomValuesFunction, isUint8Array } = require('./scripting/inbuilt modules/utils.js');
|
||||
|
||||
if (!doesUint8ArraysWorkAsExpected()) {
|
||||
console.warn('Uint8Array does not work as expected in vm2');
|
||||
return;
|
||||
}
|
||||
|
||||
// check if Uint8Array work as expected
|
||||
test("should get random values", function() {
|
||||
const uint8Array = new Uint8Array(32).fill(0);
|
||||
const randomValueUint8Array = getRandomValuesFunction(new Uint8Array(uint8Array));
|
||||
|
||||
const isValueUint8Array = isUint8Array(randomValueUint8Array);
|
||||
expect(isValueUint8Array).to.be.true;
|
||||
|
||||
const plainArray = Array.from(randomValueUint8Array);
|
||||
expect(plainArray).to.be.of.length(32);
|
||||
|
||||
const ogPlainArray = Array.from(uint8Array);
|
||||
expect(ogPlainArray).to.not.deep.eql(plainArray);
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
meta {
|
||||
name: randomBytes
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://echo.usebruno.com
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
|
||||
tests {
|
||||
const { randomBytesFunction, isUint8Array } = require('./scripting/inbuilt modules/utils.js');
|
||||
|
||||
test("should get random byte values", function() {
|
||||
const randomValueUint8Array = randomBytesFunction(32);
|
||||
|
||||
const isValueUint8Array = isUint8Array(randomValueUint8Array);
|
||||
expect(isValueUint8Array).to.be.true;
|
||||
|
||||
const plainArray = Array.from(randomValueUint8Array);
|
||||
expect(plainArray).to.be.of.length(32);
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const doesUint8ArraysWorkAsExpected = () => {
|
||||
try {
|
||||
const util = require('node:util');
|
||||
// node:vm - true
|
||||
// vm2 - false
|
||||
return util.types.isUint8Array(new Uint8Array(32));
|
||||
}
|
||||
catch (err) {
|
||||
// safe mode [quickjs], will work as expected
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const isUint8Array = (val) => {
|
||||
try {
|
||||
// developer mode [node:vm and vm2]
|
||||
const util = require('node:util');
|
||||
return util.types.isUint8Array(val);
|
||||
}
|
||||
catch (err) {
|
||||
// node:util not present in safe mode [quickjs]
|
||||
return val instanceof Uint8Array;
|
||||
}
|
||||
}
|
||||
|
||||
const getRandomValuesFunction = (typedArray) => {
|
||||
try {
|
||||
// developer mode [node:vm and vm2]
|
||||
const crypto = require('node:crypto');
|
||||
return crypto.getRandomValues(typedArray);
|
||||
}
|
||||
catch (err) {
|
||||
// node:crypto not present in safe mode [quickjs] - uses shim
|
||||
return crypto.getRandomValues(typedArray);
|
||||
}
|
||||
}
|
||||
|
||||
const randomBytesFunction = (num) => {
|
||||
try {
|
||||
// developer mode [node:vm and vm2]
|
||||
const crypto = require('node:crypto');
|
||||
return crypto.randomBytes(num);
|
||||
}
|
||||
catch (err) {
|
||||
// node:crypto not present in safe mode [quickjs] - uses shim
|
||||
return crypto.randomBytes(num);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
doesUint8ArraysWorkAsExpected,
|
||||
isUint8Array,
|
||||
getRandomValuesFunction,
|
||||
randomBytesFunction
|
||||
}
|
||||
@@ -15,9 +15,9 @@ export const test = baseTest.extend<
|
||||
},
|
||||
{
|
||||
createTmpDir: (tag?: string) => Promise<string>;
|
||||
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
|
||||
launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
|
||||
electronApp: ElectronApplication;
|
||||
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise<ElectronApplication>;
|
||||
reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string; dotEnv?: Record<string, string> }) => Promise<ElectronApplication>;
|
||||
}
|
||||
>({
|
||||
createTmpDir: [
|
||||
@@ -38,7 +38,7 @@ export const test = baseTest.extend<
|
||||
launchElectronApp: [
|
||||
async ({ playwright, createTmpDir }, use, workerInfo) => {
|
||||
const apps: ElectronApplication[] = [];
|
||||
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath } = {}) => {
|
||||
await use(async ({ initUserDataPath, userDataPath: providedUserDataPath, dotEnv = {} } = {}) => {
|
||||
const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata'));
|
||||
|
||||
// Ensure dir exists when caller supplies their own path
|
||||
@@ -68,7 +68,9 @@ export const test = baseTest.extend<
|
||||
args: [electronAppPath],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_USER_DATA_PATH: userDataPath
|
||||
ELECTRON_USER_DATA_PATH: userDataPath,
|
||||
DISABLE_SAMPLE_COLLECTION_IMPORT: 'true',
|
||||
...dotEnv
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,12 +150,12 @@ export const test = baseTest.extend<
|
||||
reuseOrLaunchElectronApp: [
|
||||
async ({ launchElectronApp }, use, testInfo) => {
|
||||
const apps: Record<string, ElectronApplication> = {};
|
||||
await use(async ({ initUserDataPath, userDataPath } = {}) => {
|
||||
await use(async ({ initUserDataPath, userDataPath, dotEnv = {} } = {}) => {
|
||||
const key = userDataPath || initUserDataPath;
|
||||
if (key && apps[key]) {
|
||||
return apps[key];
|
||||
}
|
||||
const app = await launchElectronApp({ initUserDataPath, userDataPath });
|
||||
const app = await launchElectronApp({ initUserDataPath, userDataPath, dotEnv });
|
||||
if (key) {
|
||||
apps[key] = app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Cross-Collection Drag and Drop for folder', () => {
|
||||
test('Verify cross-collection folder drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
// Create first collection - click dropdown menu first
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Wait for collection to appear and click on it
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a folder in the first collection
|
||||
// Look for the collection menu button for the source collection specifically
|
||||
const sourceCollectionContainer1 = page.locator('.collection-name').filter({ hasText: 'source-collection' });
|
||||
await sourceCollectionContainer1.locator('.collection-actions').hover();
|
||||
await sourceCollectionContainer1.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
|
||||
// Fill folder name in the modal
|
||||
await expect(page.locator('#collection-name')).toBeVisible();
|
||||
await page.locator('#collection-name').fill('test-folder');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wait for the folder to be created and appear in the sidebar
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toBeVisible();
|
||||
|
||||
// Add a request to the folder to make it more realistic
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).click({ button: 'right' });
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
|
||||
await page.getByPlaceholder('Request Name').fill('test-request-in-folder');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://httpbin.org/get');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wait for the request to be created
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Expand the folder to see the request inside
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request-in-folder' })).toBeVisible();
|
||||
|
||||
// Create second collection - click dropdown menu first
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('target-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('target-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Wait for second collection to appear and click on it
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Wait for both collections to be visible in sidebar
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
|
||||
// Locate the folder in source collection
|
||||
const sourceFolder = page.locator('.collection-item-name').filter({ hasText: 'test-folder' });
|
||||
await expect(sourceFolder).toBeVisible();
|
||||
|
||||
// Locate the target collection area (the collection name element)
|
||||
const targetCollection = page.locator('.collection-name').filter({ hasText: 'target-collection' });
|
||||
await expect(targetCollection).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation
|
||||
await sourceFolder.dragTo(targetCollection);
|
||||
|
||||
// Wait for the operation to complete
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Verify the folder has been moved to the target collection
|
||||
// Click on target collection to expand it if needed
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that the folder now appears under target collection
|
||||
const targetCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' })
|
||||
).toBeVisible();
|
||||
|
||||
// Expand the moved folder to verify the request inside is also moved
|
||||
await targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request-in-folder' })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify the folder is no longer in the source collection
|
||||
const sourceCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Verify the request is also no longer in the source collection
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request-in-folder' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Verify cross-collection folder drag and drop, a duplicate folder exist. expected to throw error toast', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Create first collection (source) - use unique names for this test
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Wait for collection to appear and click on it
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a folder in the first collection
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..')
|
||||
.locator('.collection-actions')
|
||||
.hover();
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..')
|
||||
.locator('.collection-actions .icon')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
await expect(page.locator('#collection-name')).toBeVisible();
|
||||
await page.locator('#collection-name').fill('folder-1');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-1' })).toBeVisible();
|
||||
|
||||
// Add a request to the folder to make it more realistic
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).click({ button: 'right' });
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
|
||||
await page.getByPlaceholder('Request Name').fill('http-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://httpbin.org/get');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Expand the folder to see the request inside
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).click();
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'http-request' })).toBeVisible();
|
||||
|
||||
// Create second collection (target)
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('target-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('target-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Wait for second collection to appear and click on it
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a folder with the same name in the target collection
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..')
|
||||
.locator('.collection-actions')
|
||||
.hover();
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..')
|
||||
.locator('.collection-actions .icon')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
await expect(page.locator('#collection-name')).toBeVisible();
|
||||
await page.locator('#collection-name').fill('folder-1');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Go back to source collection to drag the folder
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
|
||||
// Verify we have the folder to drag in the source collection
|
||||
const sourceFolder = page.locator('.collection-item-name').filter({ hasText: 'folder-1' }).first();
|
||||
await expect(sourceFolder).toBeVisible();
|
||||
|
||||
// Locate the target collection area
|
||||
const targetCollection = page.locator('.collection-name').filter({ hasText: 'target-collection' });
|
||||
await expect(targetCollection).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation
|
||||
await sourceFolder.dragTo(targetCollection);
|
||||
|
||||
// check for error toast notification
|
||||
await expect(page.getByText(/Error: Cannot copy.*already exists/i)).toBeVisible();
|
||||
|
||||
// source and target collection request should remain unchanged
|
||||
const sourceCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'http-request' })
|
||||
).toBeVisible();
|
||||
|
||||
const targetCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'http-request' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Cross-Collection Drag and Drop', () => {
|
||||
test('Verify request drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
// Create first collection - click dropdown menu first
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a request in the first collection
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('test-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://httpbin.org/get');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
|
||||
|
||||
// Create second collection - click dropdown menu first
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('target-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('target-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
|
||||
// Locate the request in source collection
|
||||
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'test-request' });
|
||||
await expect(sourceRequest).toBeVisible();
|
||||
|
||||
// Locate the target collection area (the collection name element)
|
||||
const targetCollection = page.locator('.collection-name').filter({ hasText: 'target-collection' });
|
||||
await expect(targetCollection).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation
|
||||
await sourceRequest.dragTo(targetCollection);
|
||||
|
||||
// Verify the request has been moved to the target collection
|
||||
// Click on target collection to expand it if needed
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
|
||||
// Check that the request now appears under target collection
|
||||
const targetCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify the request is no longer in the source collection
|
||||
const sourceCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Expected to show error toast message, when duplicate request found in drop location', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Create first collection (source-collection)
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Open collection
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a request in the first collection (request-1)
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('request-1');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://httpbin.org/get');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// check if request-1 is created and visible in sidebar
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-1' })).toBeVisible();
|
||||
|
||||
// Create second collection (target-collection)
|
||||
await page.locator('.dropdown-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
|
||||
await page.getByLabel('Name').fill('target-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('target-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Open collection
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a request in the target collection with the same name (request-1)
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('request-1');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://httpbin.org/post');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Go back to source collection to drag the request
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'request-1' }).first();
|
||||
await expect(sourceRequest).toBeVisible();
|
||||
|
||||
// Locate the target collection area
|
||||
const targetCollection = page.locator('.collection-name').filter({ hasText: 'target-collection' });
|
||||
await expect(targetCollection).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation to target-collection
|
||||
await sourceRequest.dragTo(targetCollection);
|
||||
|
||||
// check for error toast notification
|
||||
await expect(page.getByText(/Error: Cannot copy.*already exists/i)).toBeVisible();
|
||||
|
||||
// source and target collection request should remain unchanged
|
||||
const targetCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
|
||||
).toBeVisible();
|
||||
|
||||
const sourceCollectionContainer = page
|
||||
.locator('.collection-name')
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
149
tests/collection/moving-requests/tag-persistence.spec.ts
Normal file
149
tests/collection/moving-requests/tag-persistence.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Tag persistence', () => {
|
||||
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();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByText('test-collection').click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a new request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r1');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// create another request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r2');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// create another request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r3');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Add a tag to the request
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.getByText('Tagse.g., smoke, regression').click();
|
||||
await page.getByRole('textbox').nth(2).fill('smoke');
|
||||
await page.getByRole('textbox').nth(2).press('Enter');
|
||||
|
||||
// Verify the tag was added
|
||||
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
|
||||
await page.keyboard.press('Meta+s');
|
||||
|
||||
// Move the r2 request to just above r1 within the same collection
|
||||
const r3Request = page.locator('.collection-item-name').filter({ hasText: 'r3' });
|
||||
const r1Request = page.locator('.collection-item-name').filter({ hasText: 'r1' });
|
||||
|
||||
await expect(r3Request).toBeVisible();
|
||||
await expect(r1Request).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation to move r3 below r1 using source position
|
||||
await r3Request.dragTo(r1Request, {
|
||||
targetPosition: { x: 0, y: 1 }
|
||||
});
|
||||
|
||||
// Verify the requests are still in the collection and r3 is now above r1
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'r3' })).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'r1' })).toBeVisible();
|
||||
|
||||
// Click on r3 to verify the tag persisted after the move
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'r3' }).click();
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
|
||||
// Verify the tag is still present after the move
|
||||
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('verify tag persistence while moving requests between folders', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
// Create first collection - click dropdown menu first
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page.getByText('test-collection').click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a new folder
|
||||
await page.getByTitle('test-collection').click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByText('New Folder').click();
|
||||
await page.locator('#collection-name').fill('f1');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Create a new request within f1 folder
|
||||
await page.getByText('f1').click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByTitle('f1', { exact: true }).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('.dropdown-item').getByText('New Request').click()
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r1');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// create another request within f1 folder
|
||||
await page.getByTitle('f1', { exact: true }).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('.dropdown-item').getByText('New Request').click()
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r2');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Add a tag to the request
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.getByText('Tagse.g., smoke, regression').click();
|
||||
await page.getByRole('textbox').nth(2).fill('smoke');
|
||||
await page.getByRole('textbox').nth(2).press('Enter');
|
||||
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
|
||||
await page.keyboard.press('Meta+s');
|
||||
|
||||
// Create another folder
|
||||
await page.getByTitle('test-collection').click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('.dropdown-item').getByText('New Folder').click();
|
||||
await page.locator('#collection-name').fill('f2');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// open f2 folder
|
||||
await page.getByText('f2').click();
|
||||
await page.getByTitle('f2', { exact: true }).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('.dropdown-item').getByText('New Request').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('r3');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Drag and drop r2 request to f2 folder
|
||||
const r2Request = page.locator('.collection-item-name').filter({ hasText: 'r2' });
|
||||
const f2Folder = page.locator('.collection-item-name').filter({ hasText: 'f2' });
|
||||
await r2Request.dragTo(f2Folder);
|
||||
|
||||
// Verify the requests are still in the collection and r2 is now in f2 folder
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'r2' })).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'f2' })).toBeVisible();
|
||||
|
||||
// Click on r2 to verify the tag persisted after the move
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'r2' }).click();
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
|
||||
await page.getByText('api-setEnvVar-with-persist', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await page.locator('div.current-environment.collection-environment').click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
// select stage environment
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Stage' })).toBeVisible();
|
||||
@@ -27,7 +27,8 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// confirm that the environment variable is set
|
||||
await page.getByTitle('Stage', { exact: true }).click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
|
||||
@@ -42,7 +43,7 @@ test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
|
||||
await newPage.getByText('api-setEnvVar-with-persist', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await newPage.locator('div.current-environment.collection-environment').click();
|
||||
await newPage.locator('div.current-environment').click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
await expect(newPage.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
|
||||
await expect(newPage.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
|
||||
await page.getByText('api-setEnvVar-without-persist', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await page.locator('div.current-environment.collection-environment').click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
// select stage environment
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Stage' })).toBeVisible();
|
||||
@@ -21,7 +21,7 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// confirm that the environment variable is set
|
||||
await page.getByTitle('Stage', { exact: true }).click();
|
||||
await page.locator('div.current-environment').click();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
|
||||
await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
|
||||
@@ -36,7 +36,7 @@ test.describe.serial('bru.setEnvVar(name, value)', () => {
|
||||
await newPage.getByText('api-setEnvVar-without-persist', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await newPage.locator('div.current-environment.collection-environment').click();
|
||||
await newPage.locator('div.current-environment').click();
|
||||
await newPage.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// ensure that the environment variable is not persisted
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import path from 'path';
|
||||
|
||||
test.describe('Collection Environment Create Tests', () => {
|
||||
test('should import collection and create environment for request usage', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const openApiFile = path.join(__dirname, 'fixtures', 'bruno-collection.json');
|
||||
|
||||
// Import test collection
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
|
||||
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
await expect(locationModal.getByText('test_collection')).toBeVisible();
|
||||
|
||||
await page.locator('#collection-location').fill(await createTmpDir('env-test'));
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' })).toBeVisible();
|
||||
|
||||
// Configure collection
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create environment
|
||||
await page.locator('[data-testid="environment-selector-trigger"]').click();
|
||||
await expect(page.locator('[data-testid="env-tab-collection"]')).toHaveClass(/active/);
|
||||
|
||||
// Create new environment
|
||||
await page.locator('button[id="create-env"]').click();
|
||||
|
||||
// Fill environment name
|
||||
const environmentNameInput = page.locator('input[name="name"]');
|
||||
await expect(environmentNameInput).toBeVisible();
|
||||
await environmentNameInput.fill('Test Environment');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Add environment variables
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="0.name"]').fill('host');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="0.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('https://echo.usebruno.com');
|
||||
|
||||
// Add userId
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="1.name"]').fill('userId');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="1.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('1');
|
||||
|
||||
// Add postTitle
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="2.name"]').fill('postTitle');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="2.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('Test Post from Environment');
|
||||
|
||||
// Add postBody
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="3.name"]').fill('postBody');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="3.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('This is a test post body with environment variables');
|
||||
|
||||
// Add secret token
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="4.name"]').fill('secretApiToken');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="4.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('super-secret-token-12345');
|
||||
await page.locator('input[name="4.secret"]').check();
|
||||
|
||||
// Save environment
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByText('×').click();
|
||||
await expect(page.locator('.current-environment')).toContainText('Test Environment');
|
||||
|
||||
// Test GET request with environment variables
|
||||
await page.locator('.collection-item-name').first().click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('200');
|
||||
|
||||
// Verify the JSON response contains the environment variables
|
||||
const responsePane = page.locator('.response-pane');
|
||||
await expect(responsePane).toContainText('"userId": 1');
|
||||
await expect(responsePane).toContainText('"title": "Test Post from Environment"');
|
||||
await expect(responsePane).toContainText('"body": "This is a test post body with environment variables"');
|
||||
await expect(responsePane).toContainText('"apiToken": "super-secret-token-12345"');
|
||||
|
||||
// Cleanup
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ has: page.locator('#sidebar-collection-name:has-text("test_collection")') })
|
||||
.locator('.collection-actions')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "test_collection",
|
||||
"version": "1",
|
||||
"items": [
|
||||
{
|
||||
"type": "http",
|
||||
"name": "test",
|
||||
"filename": "test.bru",
|
||||
"seq": 1,
|
||||
"settings": {
|
||||
"encodeUrl": true
|
||||
},
|
||||
"tags": [],
|
||||
"request": {
|
||||
"url": "{{host}}",
|
||||
"method": "POST",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": {
|
||||
"mode": "json",
|
||||
"json": "{\n \"userId\": {{userId}},\n \"title\": \"{{postTitle}}\",\n \"body\": \"{{postBody}}\",\n \"apiToken\": \"{{secretApiToken}}\"\n}",
|
||||
"formUrlEncoded": [],
|
||||
"multipartForm": [],
|
||||
"file": []
|
||||
},
|
||||
"script": {},
|
||||
"vars": {},
|
||||
"assertions": [],
|
||||
"tests": "",
|
||||
"docs": "",
|
||||
"auth": {
|
||||
"mode": "inherit"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"environments": [],
|
||||
"brunoConfig": {
|
||||
"version": "1",
|
||||
"name": "test_collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
],
|
||||
"size": 0.000133514404296875,
|
||||
"filesCount": 1,
|
||||
"proxy": {
|
||||
"bypassProxy": "",
|
||||
"enabled": false,
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": null,
|
||||
"hostname": "",
|
||||
"protocol": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
130
tests/environments/create-environment/global-env-create.spec.ts
Normal file
130
tests/environments/create-environment/global-env-create.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import path from 'path';
|
||||
|
||||
test.describe('Global Environment Create Tests', () => {
|
||||
test('should import collection and create global environment for request usage', async ({
|
||||
pageWithUserData: page,
|
||||
createTmpDir
|
||||
}) => {
|
||||
const openApiFile = path.join(__dirname, 'fixtures', 'bruno-collection.json');
|
||||
|
||||
// Import test collection
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
await expect(locationModal.getByText('test_collection')).toBeVisible();
|
||||
|
||||
await page.locator('#collection-location').fill(await createTmpDir('global-env-test'));
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' })).toBeVisible();
|
||||
|
||||
// Configure collection
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create global environment
|
||||
await page.locator('[data-testid="environment-selector-trigger"]').click();
|
||||
await page.locator('[data-testid="env-tab-global"]').click();
|
||||
await expect(page.locator('[data-testid="env-tab-global"]')).toHaveClass(/active/);
|
||||
|
||||
// Create new global environment
|
||||
await page.locator('button[id="create-env"]').click();
|
||||
|
||||
// Fill environment name
|
||||
const environmentNameInput = page.locator('input[name="name"]');
|
||||
await expect(environmentNameInput).toBeVisible();
|
||||
await environmentNameInput.fill('Test Global Environment');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Add environment variables
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="0.name"]').fill('host');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="0.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('https://echo.usebruno.com');
|
||||
|
||||
// Add userId
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="1.name"]').fill('userId');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="1.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('1');
|
||||
|
||||
// Add postTitle
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="2.name"]').fill('postTitle');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="2.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('Global Test Post from Environment');
|
||||
|
||||
// Add postBody
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="3.name"]').fill('postBody');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="3.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('This is a global test post body with environment variables');
|
||||
|
||||
// Add secret token
|
||||
await page.locator('button[data-testid="add-variable"]').click();
|
||||
await page.locator('input[name="4.name"]').fill('secretApiToken');
|
||||
await page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('input[name="4.name"]') })
|
||||
.locator('.CodeMirror')
|
||||
.click();
|
||||
await page.keyboard.type('global-secret-token-12345');
|
||||
await page.locator('input[name="4.secret"]').check();
|
||||
await expect(page.locator('input[name="4.secret"]')).toBeChecked();
|
||||
|
||||
// Save environment
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByText('×').click();
|
||||
await expect(page.locator('.current-environment')).toContainText('Test Global Environment');
|
||||
|
||||
// Test GET request with environment variables
|
||||
await page.locator('.collection-item-name').first().click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('200');
|
||||
|
||||
// Verify the JSON response contains the environment variables
|
||||
const responsePane = page.locator('.response-pane');
|
||||
await expect(responsePane).toContainText('"userId": 1');
|
||||
await expect(responsePane).toContainText('"title": "Global Test Post from Environment"');
|
||||
await expect(responsePane).toContainText('"body": "This is a global test post body with environment variables"');
|
||||
await expect(responsePane).toContainText('"apiToken": "global-secret-token-12345"');
|
||||
|
||||
// Cleanup
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ has: page.locator('#sidebar-collection-name:has-text("test_collection")') })
|
||||
.locator('.collection-actions')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import path from 'path';
|
||||
|
||||
test.describe('Collection Environment Import Tests', () => {
|
||||
test('should import collection environment from file', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
const openApiFile = path.join(__dirname, 'fixtures', 'collection.json');
|
||||
const envFile = path.join(__dirname, 'fixtures', 'collection-env.json');
|
||||
|
||||
// Import test collection
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
await expect(locationModal.getByText('Environment Test Collection')).toBeVisible();
|
||||
|
||||
await page.locator('#collection-location').fill(await createTmpDir('collection-env-import-test'));
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })
|
||||
).toBeVisible();
|
||||
|
||||
// Configure collection
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Import collection environment
|
||||
await page.locator('[data-testid="environment-selector-trigger"]').click();
|
||||
await expect(page.locator('[data-testid="env-tab-collection"]')).toHaveClass(/active/);
|
||||
await page.locator('button[id="import-env"]').click();
|
||||
const importEnvModal = page.locator('[data-testid="import-environment-modal"]');
|
||||
await expect(importEnvModal).toBeVisible();
|
||||
|
||||
// Import environment file
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('button[data-testid="import-postman-environment"]').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(envFile);
|
||||
|
||||
// Wait for import to complete and environment settings modal to open
|
||||
await expect(page.locator('.current-environment')).toContainText('Test Collection Environment');
|
||||
|
||||
// The environment settings modal should now be visible with the imported environment
|
||||
const envSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
|
||||
await expect(envSettingsModal).toBeVisible();
|
||||
|
||||
// Verify imported variables in Test Collection Environment settings
|
||||
await expect(envSettingsModal.locator('input[name="0.name"]')).toHaveValue('host');
|
||||
await expect(envSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId');
|
||||
await expect(envSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey');
|
||||
await expect(envSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle');
|
||||
await expect(envSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody');
|
||||
await expect(envSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
|
||||
await expect(envSettingsModal.locator('input[name="5.secret"]')).toBeChecked();
|
||||
await page.getByText('×').click();
|
||||
|
||||
// Test GET request with imported environment
|
||||
await page.locator('.collection-item-name').first().click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('200');
|
||||
|
||||
// Verify the JSON response contains the interpolated userId
|
||||
const responsePane = page.locator('.response-pane');
|
||||
await expect(responsePane).toContainText('"userId": 1');
|
||||
|
||||
// Test POST request
|
||||
await page.locator('.collection-item-name').nth(1).click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('201');
|
||||
|
||||
// Cleanup
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ has: page.locator('#sidebar-collection-name:has-text("Environment Test Collection")') })
|
||||
.locator('.collection-actions')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "test-collection-env-id",
|
||||
"name": "Test Collection Environment",
|
||||
"values": [
|
||||
{
|
||||
"key": "host",
|
||||
"value": "https://jsonplaceholder.typicode.com",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "userId",
|
||||
"value": "1",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "apiKey",
|
||||
"value": "collection-api-key-12345",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "postTitle",
|
||||
"value": "Collection Environment Test Post",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "postBody",
|
||||
"value": "This is a test post created using collection environment variables",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "secretApiToken",
|
||||
"value": "collection-secret-token-67890",
|
||||
"enabled": true,
|
||||
"type": "secret"
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "environment",
|
||||
"_postman_exported_at": "2024-01-01T00:00:00.000Z",
|
||||
"_postman_exported_using": "Postman/10.0.0"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Environment Test Collection",
|
||||
"description": "Test collection for environment import and usage tests",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_postman_id": "env-test-collection-id"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get Posts with Environment Variables",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{apiKey}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "X-Secret-Token",
|
||||
"value": "{{secretApiToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{host}}/posts/{{userId}}",
|
||||
"host": ["{{host}}"],
|
||||
"path": ["posts", "{{userId}}"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Post with Body Variables",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{apiKey}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "X-Secret-Token",
|
||||
"value": "{{secretApiToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"{{postTitle}}\",\n \"body\": \"{{postBody}}\",\n \"userId\": {{userId}}\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{host}}/posts",
|
||||
"host": ["{{host}}"],
|
||||
"path": ["posts"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "test-global-env-id",
|
||||
"name": "Test Global Environment",
|
||||
"values": [
|
||||
{
|
||||
"key": "host",
|
||||
"value": "https://jsonplaceholder.typicode.com",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "userId",
|
||||
"value": "1",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "apiKey",
|
||||
"value": "global-api-key-12345",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "postTitle",
|
||||
"value": "Global Test Post from Environment",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "postBody",
|
||||
"value": "This is a global test post body with environment variables",
|
||||
"enabled": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "secretApiToken",
|
||||
"value": "global-secret-token-67890",
|
||||
"enabled": true,
|
||||
"type": "secret"
|
||||
}
|
||||
],
|
||||
"_postman_variable_scope": "globals",
|
||||
"_postman_exported_at": "2024-01-01T00:00:00.000Z",
|
||||
"_postman_exported_using": "Postman/10.0.0"
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import path from 'path';
|
||||
|
||||
test.describe('Global Environment Import Tests', () => {
|
||||
test('should import global environment from file', async ({ pageWithUserData: page, createTmpDir }) => {
|
||||
const openApiFile = path.join(__dirname, 'fixtures', 'collection.json');
|
||||
const globalEnvFile = path.join(__dirname, 'fixtures', 'global-env.json');
|
||||
|
||||
// Import test collection
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
|
||||
await page.setInputFiles('input[type="file"]', openApiFile);
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
|
||||
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
await expect(locationModal.getByText('Environment Test Collection')).toBeVisible();
|
||||
|
||||
await page.locator('#collection-location').fill(await createTmpDir('global-env-import-test'));
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })
|
||||
).toBeVisible();
|
||||
|
||||
// Configure collection
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Import global environment
|
||||
await page.locator('[data-testid="environment-selector-trigger"]').click();
|
||||
await page.locator('[data-testid="env-tab-global"]').click();
|
||||
await expect(page.locator('[data-testid="env-tab-global"]')).toHaveClass(/active/);
|
||||
await page.locator('button[id="import-env"]').click();
|
||||
const importGlobalEnvModal = page.locator('[data-testid="import-global-environment-modal"]');
|
||||
await expect(importGlobalEnvModal).toBeVisible();
|
||||
|
||||
// Import environment file
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.locator('button[data-testid="import-postman-global-environment"]').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(globalEnvFile);
|
||||
|
||||
// Wait for import to complete and global environment settings modal to open
|
||||
await expect(page.locator('.current-environment')).toContainText('Test Global Environment');
|
||||
|
||||
// The global environment settings modal should now be visible with the imported environment
|
||||
const globalEnvSettingsModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
|
||||
await expect(globalEnvSettingsModal).toBeVisible();
|
||||
|
||||
// Verify imported variables in Test Global Environment settings
|
||||
await expect(globalEnvSettingsModal.locator('input[name="0.name"]')).toHaveValue('host');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="1.name"]')).toHaveValue('userId');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="2.name"]')).toHaveValue('apiKey');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="3.name"]')).toHaveValue('postTitle');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="4.name"]')).toHaveValue('postBody');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="5.name"]')).toHaveValue('secretApiToken');
|
||||
await expect(globalEnvSettingsModal.locator('input[name="5.secret"]')).toBeChecked();
|
||||
await page.getByText('×').click();
|
||||
|
||||
// Test GET request with global environment
|
||||
await page.locator('.collection-item-name').first().click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('200');
|
||||
|
||||
// Verify the JSON response contains the interpolated userId
|
||||
const responsePane = page.locator('.response-pane');
|
||||
await expect(responsePane).toContainText('"userId": 1');
|
||||
|
||||
// Test POST request
|
||||
await page.locator('.collection-item-name').nth(1).click();
|
||||
await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts');
|
||||
await page.locator('[data-testid="send-arrow-icon"]').click();
|
||||
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('201');
|
||||
|
||||
// Cleanup
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
|
||||
await page
|
||||
.locator('.collection-name')
|
||||
.filter({ has: page.locator('#sidebar-collection-name:has-text("Environment Test Collection")') })
|
||||
.locator('.collection-actions')
|
||||
.click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ test.describe('Multiline Variables - Read Environment Test', () => {
|
||||
await page.getByTitle('request', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await page.locator('div.current-environment.collection-environment').click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
// select test environment
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible();
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Multiline Variables - Write Test', () => {
|
||||
await page.getByTitle('multiline-test', { exact: true }).click();
|
||||
|
||||
// open environment dropdown
|
||||
await page.locator('div.current-environment.collection-environment').click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
// select test environment
|
||||
await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible();
|
||||
@@ -21,12 +21,11 @@ test.describe('Multiline Variables - Write Test', () => {
|
||||
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
|
||||
|
||||
// select configure button from environment dropdown
|
||||
await expect(page.getByTitle('Test', { exact: true })).toBeVisible();
|
||||
await page.getByTitle('Test', { exact: true }).click();
|
||||
await page.locator('div.current-environment').click();
|
||||
|
||||
// open environment configuration
|
||||
await expect(page.locator('#Configure')).toBeVisible();
|
||||
await page.locator('#Configure').click();
|
||||
await expect(page.getByText('Configure', { exact: true })).toBeVisible();
|
||||
await page.getByText('Configure', { exact: true }).click();
|
||||
|
||||
// add variable
|
||||
await page.getByRole('button', { name: /Add.*Variable/i }).click();
|
||||
|
||||
@@ -6,22 +6,22 @@ test.describe('Invalid File Handling', () => {
|
||||
|
||||
test('Handle invalid file without crashing', async ({ page }) => {
|
||||
const invalidFile = path.join(testDataDir, 'invalid.txt');
|
||||
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
|
||||
await page.setInputFiles('input[type="file"]', invalidFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
const hasError = await page.getByText("Failed to parse the file – ensure it is valid JSON or YAML").isVisible();
|
||||
|
||||
const hasError = await page.getByText("Failed to parse the file – ensure it is valid JSON or YAML").first().isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
|
||||
// Cleanup: close any open modals
|
||||
await page.locator('[data-test-id="modal-close-button"]').click();
|
||||
});
|
||||
|
||||
@@ -8,19 +8,19 @@ test.describe('Invalid Postman Collection - Missing Info', () => {
|
||||
const postmanFile = path.join(testDataDir, 'postman-invalid-missing-info.json');
|
||||
|
||||
await page.getByRole('button', { name: 'Import Collection' }).click();
|
||||
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
const importModal = page.getByRole('dialog');
|
||||
await importModal.waitFor({ state: 'visible' });
|
||||
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
||||
|
||||
|
||||
await page.setInputFiles('input[type="file"]', postmanFile);
|
||||
|
||||
// Wait for the loader to disappear
|
||||
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
|
||||
|
||||
|
||||
// Check for error message
|
||||
const hasError = await page.getByText('Import collection failed').isVisible();
|
||||
const hasError = await page.getByText('Import collection failed').first().isVisible();
|
||||
expect(hasError).toBe(true);
|
||||
|
||||
// Cleanup: close any open modals
|
||||
|
||||
10
tests/onboarding/init-user-data/preferences.json
Normal file
10
tests/onboarding/init-user-data/preferences.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/packages/bruno-tests/collection"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true
|
||||
}
|
||||
}
|
||||
}
|
||||
133
tests/onboarding/sample-collection.spec.ts
Normal file
133
tests/onboarding/sample-collection.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import path from 'path';
|
||||
import { test, expect, errors } from '../../playwright';
|
||||
|
||||
const env = {
|
||||
DISABLE_SAMPLE_COLLECTION_IMPORT: 'false'
|
||||
};
|
||||
|
||||
test.describe('Onboarding', () => {
|
||||
test('should create sample collection on first launch', async ({ launchElectronApp, createTmpDir }) => {
|
||||
|
||||
// Use a fresh app instance to avoid contamination from previous tests
|
||||
const userDataPath = await createTmpDir('onboarding-fresh');
|
||||
const app = await launchElectronApp({ userDataPath, dotEnv: env });
|
||||
const page = await app.firstWindow();
|
||||
|
||||
// Verify sample collection appears in sidebar
|
||||
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollection).toBeVisible();
|
||||
|
||||
// Click on the sample collection to open it
|
||||
await sampleCollection.click();
|
||||
const modeSaveButton = page.getByRole('button', { name: 'Save' });
|
||||
await expect(modeSaveButton).toBeVisible();
|
||||
await modeSaveButton.click();
|
||||
|
||||
// Verify the sample request is visible and clickable
|
||||
const request = page.locator('.collection-item-name').getByText('Get Users');
|
||||
await expect(request).toBeVisible();
|
||||
await request.click();
|
||||
|
||||
// Verify the URL is set correctly
|
||||
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
|
||||
|
||||
// Clean up
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should not create duplicate collections on subsequent launches', async ({ launchElectronApp, createTmpDir }) => {
|
||||
// Use a fresh app instance to avoid contamination from previous tests
|
||||
const userDataPath = await createTmpDir('duplicate-collections');
|
||||
const app = await launchElectronApp({ userDataPath, dotEnv: env });
|
||||
const page = await app.firstWindow();
|
||||
|
||||
// First launch - verify sample collection is created
|
||||
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollection).toBeVisible();
|
||||
await sampleCollection.click();
|
||||
const modeSaveButton = page.getByRole('button', { name: 'Save' });
|
||||
await expect(modeSaveButton).toBeVisible();
|
||||
await modeSaveButton.click();
|
||||
|
||||
// Verify the sample request
|
||||
const request = page.locator('.collection-item-name').getByText('Get Users');
|
||||
await expect(request).toBeVisible();
|
||||
await request.click();
|
||||
|
||||
// Verify the URL is set correctly
|
||||
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
|
||||
|
||||
// Close the first app instance
|
||||
await app.close();
|
||||
|
||||
// Restart app - should not create sample collection again
|
||||
const newApp = await launchElectronApp({ userDataPath, dotEnv: env });
|
||||
const newPage = await newApp.firstWindow();
|
||||
|
||||
// Verify only one sample collection exists
|
||||
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollections).toHaveCount(1);
|
||||
|
||||
// Verify the collection still works after restart
|
||||
await sampleCollections.click();
|
||||
const request2 = newPage.locator('.collection-item-name').getByText('Get Users');
|
||||
await expect(request2).toBeVisible();
|
||||
await request2.click();
|
||||
|
||||
// Verify the URL is still correct after restart
|
||||
await expect(newPage.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
|
||||
|
||||
// Clean up
|
||||
await newApp.close();
|
||||
});
|
||||
|
||||
test('should not recreate sample collection after user deletes it', async ({ launchElectronApp, reuseOrLaunchElectronApp, createTmpDir }) => {
|
||||
const userDataPath = await createTmpDir('first-launch');
|
||||
const app = await launchElectronApp({ userDataPath, dotEnv: env });
|
||||
const page = await app.firstWindow();
|
||||
|
||||
// First launch - sample collection should be created
|
||||
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollection).toBeVisible();
|
||||
|
||||
// User closes the sample collection (right-click to open context menu)
|
||||
await sampleCollection.click({ button: 'right' });
|
||||
|
||||
// Close the sample collection
|
||||
const closeOption = page.locator('.dropdown-item').getByText('Close');
|
||||
await expect(closeOption).toBeVisible();
|
||||
await closeOption.click();
|
||||
|
||||
// Handle the confirmation dialog - click the 'Close' button to confirm
|
||||
const confirmCloseButton = page.getByRole('button', { name: 'Close' });
|
||||
await expect(confirmCloseButton).toBeVisible();
|
||||
await confirmCloseButton.click();
|
||||
|
||||
// Verify collection is closed (no longer visible in sidebar)
|
||||
await expect(sampleCollection).not.toBeVisible();
|
||||
|
||||
// Restart app - sample collection should NOT be recreated
|
||||
const newApp = await reuseOrLaunchElectronApp({ userDataPath, dotEnv: env });
|
||||
const newPage = await newApp.firstWindow();
|
||||
|
||||
// Wait for the app to be loaded / onboarding to be completed
|
||||
await newPage.locator('[data-app-state="loaded"]').waitFor();
|
||||
|
||||
// Sample collection should not appear since it's no longer first launch
|
||||
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollections).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not create sample collection if user has already opened a collection', async ({ pageWithUserData: page }) => {
|
||||
// Wait for the app to be loaded / onboarding to be completed
|
||||
await page.locator('[data-app-state="loaded"]').waitFor();
|
||||
|
||||
// This test simulates old users who already have a collection opened
|
||||
const brunoTestbench = page.locator('#sidebar-collection-name').getByText('bruno-testbench');
|
||||
await expect(brunoTestbench).toBeVisible();
|
||||
|
||||
// Verify no sample collection was created since user already has collections
|
||||
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
|
||||
await expect(sampleCollection).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function normalizeJunitReport(xmlContent: string): string {
|
||||
return xmlContent
|
||||
// Replace timestamps with fixed value
|
||||
.replace(/timestamp="[^"]*"/g, 'timestamp="2024-01-01T00:00:00.000"')
|
||||
// Replace hostnames with fixed value
|
||||
.replace(/hostname="[^"]*"/g, 'hostname="test-host"')
|
||||
// Replace execution times with fixed value
|
||||
.replace(/time="[^"]*"/g, 'time="0.100"')
|
||||
// Replace file paths with normalized path
|
||||
.replace(/name="[^"]*\/[^"]*"/g, 'name="/test/path/collection"');
|
||||
}
|
||||
|
||||
test.describe('Collection Run Report Tests', () => {
|
||||
const collectionPath = path.join(__dirname, 'collection');
|
||||
|
||||
test('CLI: Run collection and generate JUnit report', async ({ createTmpDir }) => {
|
||||
const outputDir = await createTmpDir('junit-report');
|
||||
const junitOutputPath = path.join(outputDir, 'cli-report.xml');
|
||||
|
||||
// Run collection via CLI with JUnit reporter
|
||||
const command = `cd "${collectionPath}" && node ../../../../packages/bruno-cli/bin/bru.js run --reporter-junit "${junitOutputPath}"`;
|
||||
|
||||
try {
|
||||
execSync(command, { stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
// CLI may exit with non-zero code if tests fail, which is expected
|
||||
console.log('CLI execution completed with exit code:', error.status);
|
||||
}
|
||||
|
||||
// Verify report was generated
|
||||
expect(fs.existsSync(junitOutputPath)).toBe(true);
|
||||
const junitReportContent = fs.readFileSync(junitOutputPath, 'utf8');
|
||||
|
||||
// Snapshot the normalized XML
|
||||
const normalizedJunitReport = normalizeJunitReport(junitReportContent);
|
||||
expect(normalizedJunitReport).toMatchSnapshot('cli-junit-report.xml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite name="/test/path/collection" errors="0" failures="0" skipped="0" tests="4" timestamp="2024-01-01T00:00:00.000" hostname="test-host" time="0.100">
|
||||
<testcase name="Status code is 200" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response is an object" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response has slideshow property" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Slideshow has title" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
</testsuite>
|
||||
<testsuite name="/test/path/collection" errors="0" failures="1" skipped="0" tests="5" timestamp="2024-01-01T00:00:00.000" hostname="test-host" time="0.100">
|
||||
<testcase name="This test will fail" status="fail" classname="/test/path/collection" time="0.100">
|
||||
<failure type="failure" message="expected 200 to equal 404"/>
|
||||
</testcase>
|
||||
<testcase name="Status code is 200" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response is an object" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response has uuid property" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="UUID is a string" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
</testsuite>
|
||||
<testsuite name="/test/path/collection" errors="0" failures="0" skipped="0" tests="3" timestamp="2024-01-01T00:00:00.000" hostname="test-host" time="0.100">
|
||||
<testcase name="Status code is 200" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response has json field" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
<testcase name="Response json has username" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
</testsuite>
|
||||
<testsuite name="/test/path/collection" errors="0" failures="1" skipped="0" tests="2" timestamp="2024-01-01T00:00:00.000" hostname="test-host" time="0.100">
|
||||
<testcase name="This test will also fail" status="fail" classname="/test/path/collection" time="0.100">
|
||||
<failure type="failure" message="expected 200 to equal 500"/>
|
||||
</testcase>
|
||||
<testcase name="Status code is 200" status="pass" classname="/test/path/collection" time="0.100"/>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -0,0 +1,38 @@
|
||||
meta {
|
||||
name: Get UUID
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbin.org/uuid
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
tests {
|
||||
test("This test will fail", function() {
|
||||
expect(res.getStatus()).to.equal(404); // Intentional failure
|
||||
});
|
||||
|
||||
test("Status code is 200", function() {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("Response is an object", function() {
|
||||
expect(res.getBody()).to.be.an('object');
|
||||
});
|
||||
|
||||
test("Response has uuid property", function() {
|
||||
expect(res.getBody()).to.have.property('uuid');
|
||||
});
|
||||
|
||||
test("UUID is a string", function() {
|
||||
const body = res.getBody();
|
||||
expect(body.uuid).to.be.a('string');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
meta {
|
||||
name: Get User Info
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbin.org/json
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
tests {
|
||||
test("Status code is 200", function() {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("Response is an object", function() {
|
||||
expect(res.getBody()).to.be.an('object');
|
||||
});
|
||||
|
||||
test("Response has slideshow property", function() {
|
||||
expect(res.getBody()).to.have.property('slideshow');
|
||||
});
|
||||
|
||||
test("Slideshow has title", function() {
|
||||
const body = res.getBody();
|
||||
expect(body.slideshow).to.have.property('title');
|
||||
});
|
||||
}
|
||||
38
tests/runner/collection-run-report/collection/auth/login.bru
Normal file
38
tests/runner/collection-run-report/collection/auth/login.bru
Normal file
@@ -0,0 +1,38 @@
|
||||
meta {
|
||||
name: Login Request
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://httpbin.org/post
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Content-Type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("Status code is 200", function() {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("Response has json field", function() {
|
||||
const response = res.getBody();
|
||||
expect(response).to.have.property('json');
|
||||
});
|
||||
|
||||
test("Response json has username", function() {
|
||||
const response = res.getBody();
|
||||
expect(response.json).to.have.property('username');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
meta {
|
||||
name: Logout Request
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: https://httpbin.org/delete
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
tests {
|
||||
test("This test will also fail", function() {
|
||||
expect(res.getStatus()).to.equal(500); // Intentional failure
|
||||
});
|
||||
|
||||
test("Status code is 200", function() {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
}
|
||||
9
tests/runner/collection-run-report/collection/bruno.json
Normal file
9
tests/runner/collection-run-report/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Report Test Collection",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user