fix(bru-1142): collection and global environments import and export functionality updates (#5910)

This commit is contained in:
lohit
2025-10-30 19:37:44 +05:30
committed by GitHub
parent 6e8751a27a
commit 21e8615247
56 changed files with 2468 additions and 236 deletions

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
/* Environment item styling */
.environment-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.15s ease;
.environment-name {
color: ${(props) => props.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect, useMemo } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal';
import { exportBrunoEnvironment } from 'utils/exporters/bruno-environment';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const ExportEnvironmentModal = ({ onClose, environments = [], environmentType }) => {
const dispatch = useDispatch();
// Helper function to truncate environment names
const truncateEnvName = (name) => {
if (name.length > 40) {
return name.substring(0, 40) + '...';
}
return name;
};
const [isExporting, setIsExporting] = useState(false);
const [filePath, setFilePath] = useState('');
const [selectedEnvironments, setSelectedEnvironments] = useState({});
const [exportFormat, setExportFormat] = useState(environments.length > 1 ? 'single-file' : 'single-object');
// Initialize selected environments
useEffect(() => {
const initialSelection = {};
// Add all environments and select them by default
environments.forEach((env) => {
initialSelection[env.uid] = true;
});
setSelectedEnvironments(initialSelection);
}, [environments]);
useEffect(() => {
const selectedCount = Object.values(selectedEnvironments).filter(Boolean).length;
if (selectedCount <= 1) {
setExportFormat('single-object');
}
if (exportFormat === 'single-object' && selectedCount > 1) {
setExportFormat('single-file');
}
}, [selectedEnvironments]);
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
setFilePath(dirPath);
}
})
.catch((error) => {
setFilePath('');
console.error(error);
});
};
const handleEnvironmentToggle = (envUid) => {
setSelectedEnvironments((prev) => {
const newSelection = {
...prev,
[envUid]: !prev[envUid]
};
return newSelection;
});
};
const handleSelectAll = () => {
const allSelected = environments.every((env) => selectedEnvironments[env.uid]) || false;
const newSelection = environments.reduce((acc, env) => ({
...acc,
[env.uid]: !allSelected
}), {}) || {};
setSelectedEnvironments(newSelection);
};
// Memoized selected environments and count
const selectedEnvs = useMemo(() => {
return environments.filter((env) => selectedEnvironments[env.uid]) || [];
}, [environments, selectedEnvironments]);
const selectedCount = selectedEnvs.length;
const exportFormatOptions = useMemo(() => {
const isMultiple = selectedCount > 1;
if (isMultiple) {
return [
{ value: 'single-file', label: 'Single JSON file', description: 'All environments in one JSON array' },
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: false }
];
}
return [
{ value: 'single-object', label: 'Single JSON file', description: 'Export as a single environment JSON object' },
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: true }
];
}, [selectedCount, exportFormat]);
const handleExport = async () => {
try {
setIsExporting(true);
if (!filePath) {
toast.error('Please select a location to save the files');
return;
}
if (selectedCount === 0) {
toast.error('Please select at least one environment to export');
return;
}
await exportBrunoEnvironment({ environments: selectedEnvs, environmentType, filePath, exportFormat });
const successMessage = exportFormat === 'folder'
? `Environments exported successfully to bruno-${environmentType}-environments folder`
: 'Environment(s) exported successfully';
toast.success(successMessage);
onClose();
} catch (error) {
console.error('Export error:', error);
toast.error(error.message || 'Failed to export environments');
} finally {
setIsExporting(false);
}
};
return (
<Portal>
<StyledWrapper>
<Modal
size="md"
title="Export Environments"
hideFooter={true}
handleCancel={onClose}
>
<div className="py-2">
{/* Environments Section */}
<div className="mb-4">
{environments && environments.length > 0 ? (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2 pb-1">
<h3 className="font-semibold text-sm text-theme">
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
</h3>
<button
type="button"
onClick={handleSelectAll}
className="text-xs text-link px-1 py-0.5 rounded transition-colors"
>
{environments.every((env) => selectedEnvironments[env.uid]) ? 'Deselect All' : 'Select All'}
</button>
</div>
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
{environments.map((env) => (
<label key={env.uid} className="environment-item">
<input
type="checkbox"
checked={selectedEnvironments[env.uid] || false}
onChange={() => handleEnvironmentToggle(env.uid)}
disabled={isExporting}
className="w-3.5 h-3.5 flex-shrink-0"
/>
<span className="environment-name">{truncateEnvName(env.name)}</span>
</label>
))}
</div>
</div>
) : (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2 pb-1">
<h3 className="font-semibold text-sm text-theme">
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
</h3>
</div>
<div className="flex items-center justify-center flex-1 p-4 text-center">
<span className="text-xs text-muted">
No {environmentType === 'global' ? 'global' : 'collection'} environments
</span>
</div>
</div>
)}
</div>
{/* Export Format Section */}
{selectedCount > 0 && (
<div className="mb-4">
<label className="block text-sm font-medium mb-2 text-theme">
Export Format
</label>
<div className="space-y-2">
{exportFormatOptions.map((option) => (
<label key={option.value} className={`flex items-start p-2 rounded transition-colors ${option.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
<input
type="radio"
name="exportFormat"
value={option.value}
checked={exportFormat === option.value}
onChange={(e) => setExportFormat(e.target.value)}
disabled={isExporting || option.disabled}
className={`mt-0.5 mr-3 w-4 h-4 ${option.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
<div>
<div className={`text-sm font-medium ${option.disabled ? 'text-muted' : 'text-theme'}`}>{option.label}</div>
<div className="text-xs text-muted">{option.description}</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Location Input Section */}
<div className="mb-4">
<label htmlFor="export-location" className="block text-sm font-medium mb-2 text-theme">
Location
</label>
<div className="flex flex-col relative items-center">
<input
id="export-location"
type="text"
className={`flex-1 textbox w-full ${isExporting || selectedCount <= 0 ? '' : 'cursor-pointer'}`}
title={filePath}
value={filePath}
onClick={browse}
onChange={(e) => setFilePath(e.target.value)}
disabled={isExporting || selectedCount <= 0}
placeholder="Select a target location"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
</div>
{/* Export Actions */}
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
className="btn btn-sm btn-cancel mt-2 flex items-center"
onClick={onClose}
disabled={isExporting}
>
Cancel
</button>
<button
type="button"
className="btn btn-sm btn-secondary mt-2 flex items-center"
onClick={handleExport}
disabled={isExporting || selectedCount === 0}
>
{isExporting ? 'Exporting...' : `Export ${selectedCount || ''} Environment${selectedCount !== 1 ? 's' : ''}`}
</button>
</div>
</div>
</Modal>
</StyledWrapper>
</Portal>
);
};
export default ExportEnvironmentModal;

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import importBrunoEnvironment from 'utils/importers/bruno-environment';
import { readMultipleFiles } from 'utils/importers/file-reader';
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { toastError } from 'utils/common/error';
import { IconFileImport } from '@tabler/icons';
const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const [isDragOver, setIsDragOver] = useState(false);
const isGlobal = type === 'global';
// Validate required props
if (!isGlobal && !collection) {
console.error('ImportEnvironmentModal: collection prop is required when type is "collection"');
return null;
}
const modalTitle = isGlobal ? 'Import Global Environment' : 'Import Environment';
const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal';
const importTestId = isGlobal ? 'import-global-environment' : 'import-environment';
const processEnvironments = async (environments, successMessage) => {
const validEnvironments = environments.filter((env) => {
if (env.name && env.name !== 'undefined') {
return true;
} else {
toast.error('Failed to import environment: env has no name');
return false;
}
});
if (validEnvironments.length === 0) {
toast.error('No valid environments found to import');
return;
}
try {
// Process environments sequentially to ensure unique name checking considers previously imported environments
let importedCount = 0;
for (const environment of validEnvironments) {
const action = isGlobal
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
await dispatch(action);
importedCount++;
}
toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`);
} catch (error) {
toast.error('An error occurred while importing the environment(s)');
console.error(error);
throw error;
}
};
const detectEnvironmentFormat = (data) => {
// bruno environment `single-object` export type
if (data.info && data.info.type === 'bruno-environment') {
return 'bruno';
} else if (Array.isArray(data)) {
// bruno environment`single-file` export type
return data.some((env) => env.info && env.info.type === 'bruno-environment') ? 'bruno' : 'postman';
} else if (data.id && data.values) {
// postman environment
return 'postman';
}
return 'bruno';
};
const handleImportEnvironment = async (files) => {
try {
// Read and parse all files
const parsedFiles = await readMultipleFiles(Array.from(files));
// Detect format from first file's content
const format = detectEnvironmentFormat(parsedFiles[0].content);
let environments;
if (format === 'postman') {
environments = await importPostmanEnvironment(parsedFiles);
} else {
environments = await importBrunoEnvironment(parsedFiles);
}
await processEnvironments(environments);
onClose();
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
} catch (err) {
toastError(err, 'Import environment failed');
}
};
const handleFileSelect = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.json';
input.onchange = (e) => {
if (e.target.files && e.target.files.length > 0) {
handleImportEnvironment(e.target.files);
}
};
input.click();
};
const handleDragOver = (e) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleImportEnvironment(files);
}
};
return (
<Portal>
<Modal size="md" title={modalTitle} hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId={modalTestId}>
<div className="py-2">
<div
className={`flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
isDragOver
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20'
: 'border-zinc-300 dark:border-zinc-400 hover:border-zinc-400'
}`}
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
data-testid={importTestId}
>
<IconFileImport size={64} />
<span className="mt-2 block text-sm font-semibold">
{isDragOver ? 'Drop your environment files here' : 'Import your environments'}
</span>
<span className="mt-1 block text-xs text-muted">
Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats.
</span>
</div>
</div>
</Modal>
</Portal>
);
};
export default ImportEnvironmentModal;

View File

@@ -11,9 +11,8 @@ 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 ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -242,7 +241,8 @@ const EnvironmentSelector = ({ collection }) => {
)}
{showImportGlobalModal && (
<ImportGlobalEnvironment
<ImportEnvironmentModal
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
@@ -261,7 +261,8 @@ const EnvironmentSelector = ({ collection }) => {
)}
{showImportCollectionModal && (
<ImportEnvironment
<ImportEnvironmentModal
type="collection"
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {

View File

@@ -4,6 +4,7 @@ import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
const [openEditModal, setOpenEditModal] = useState(false);
@@ -30,10 +31,22 @@ const EnvironmentDetails = ({ environment, collection, setIsModified, onClose })
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash
className="cursor-pointer"
size={20}
strokeWidth={1.5}
onClick={() => setOpenDeleteModal(true)}
data-testid="delete-environment-button"
/>
</ToolHint>
</div>
</div>

View File

@@ -3,15 +3,15 @@ import { findEnvironmentInCollection } from 'utils/collections';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import ImportEnvironment from '../ImportEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ManageSecrets from '../ManageSecrets';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ToolHint from 'components/ToolHint';
import { isEqual } from 'lodash';
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose }) => {
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
@@ -96,7 +96,7 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment collection={collection} onClose={() => setOpenImportModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
@@ -129,6 +129,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>

View File

@@ -1,64 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
.then(() => {
toast.success('Environment imported successfully');
})
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.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} 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>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@@ -3,8 +3,9 @@ import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ImportEnvironment from './ImportEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { IconFileAlert } from '@tabler/icons';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
@@ -47,6 +48,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
const { environments } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
@@ -54,7 +56,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
@@ -64,16 +66,26 @@ const EnvironmentSettings = ({ collection, onClose }) => {
}
return (
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
/>
</Modal>
<StyledWrapper>
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={collection.environments}
environmentType="collection"
/>
)}
</StyledWrapper>
);
};

View File

@@ -4,8 +4,9 @@ import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -29,15 +30,26 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
</ToolHint>
</div>
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
allEnvironments={allEnvironments}
/>
</div>
</div>
);

View File

@@ -2,15 +2,15 @@ import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironment from '../ImportEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
@@ -38,7 +38,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
return;
}
const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
@@ -90,6 +90,12 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
setOpenManageSecretsModal(true);
};
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
@@ -99,7 +105,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
@@ -132,6 +138,10 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
@@ -144,6 +154,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
allEnvironments={environments}
/>
</div>
</StyledWrapper>

View File

@@ -1,65 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.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} 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>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@@ -4,7 +4,8 @@ import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironment from './ImportEnvironment/index';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
@@ -44,6 +45,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
@@ -51,7 +53,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
@@ -61,17 +63,27 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
}
return (
<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>
<StyledWrapper>
<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}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environmentType="global"
/>
)}
</StyledWrapper>
);
};

View File

@@ -1339,7 +1339,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
});
};
export const importEnvironment = (name, variables, collectionUid) => (dispatch, getState) => {
export const importEnvironment = ({ name, variables, collectionUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);

View File

@@ -87,13 +87,17 @@ export const {
_deleteGlobalEnvironment
} = globalEnvironmentsSlice.actions;
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
.then((result) => {
const finalName = result?.name || name;
dispatch(_addGlobalEnvironment({ name: finalName, uid, variables }));
})
.then(() => dispatch(selectGlobalEnvironment({ environmentUid: uid })))
.then(resolve)
.catch(reject);
@@ -109,7 +113,11 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
.then((result) => {
// Use the unique name returned by the IPC handler
const finalName = result?.name || name;
dispatch(_copyGlobalEnvironment({ name: finalName, uid, variables: baseEnv.variables }));
})
.then(resolve)
.catch(reject);
});

View File

@@ -29,3 +29,13 @@ export const buildPersistedEnvVariables = (variables, { mode, persistedNames } =
// default to save mode
return src.map(toPersistedEnvVarForSave);
};
export const buildEnvVariable = (obj) => {
return {
name: obj.name ?? '',
value: !!obj.secret ? '' : (obj.value ?? ''),
type: 'text',
enabled: obj.enabled !== false,
secret: !!obj.secret
};
};

View File

@@ -0,0 +1,23 @@
import { buildEnvVariable } from 'utils/environments';
export const exportBrunoEnvironment = async ({ environments, environmentType, filePath, exportFormat = 'folder' }) => {
try {
const { ipcRenderer } = window;
let cleanEnvironments = environments.map((environment) => ({
name: environment.name,
variables: (environment.variables || []).map(buildEnvVariable)
}));
await ipcRenderer.invoke('renderer:export-environment', {
environments: cleanEnvironments,
environmentType,
format: 'json',
filePath,
exportFormat
});
} catch (error) {
console.error(`Error exporting ${environmentType} environment as .json:`, error);
throw new Error(`Failed to export ${environmentType} environments.`);
}
};

View File

@@ -0,0 +1,90 @@
import { BrunoError } from 'utils/common/error';
import { buildEnvVariable } from 'utils/environments';
const validateBrunoEnvironment = (env) => {
if (!env || typeof env !== 'object') {
throw new BrunoError('Invalid environment: expected an object');
}
if (!Array.isArray(env.variables)) {
throw new BrunoError('Invalid environment: missing or invalid variables array');
}
// Validate each variable
env.variables.forEach((variable, index) => {
if (!variable || typeof variable !== 'object') {
throw new BrunoError(`Invalid variable at index ${index}: expected an object`);
}
if (!variable.name || typeof variable.name !== 'string') {
throw new BrunoError(`Invalid variable at index ${index}: missing or invalid name`);
}
});
return {
name: env.name || 'Imported Environment',
variables: env.variables.map(buildEnvVariable)
};
};
const processEnvironmentData = (data, fileName) => {
try {
// Handle new single-file format with environments array
if (data.info && data.info.type === 'bruno-environment' && Array.isArray(data.environments)) {
return data.environments.map((env, index) => {
try {
return validateBrunoEnvironment(env);
} catch (err) {
throw new BrunoError(`Error in environment ${index + 1} from ${fileName}: ${err.message}`);
}
});
}
// Handle array of environments (old format)
if (Array.isArray(data)) {
return data.map((env, index) => {
try {
return validateBrunoEnvironment(env);
} catch (err) {
throw new BrunoError(`Error in environment ${index + 1} from ${fileName}: ${err.message}`);
}
});
}
// Handle single environment object
return [validateBrunoEnvironment(data)];
} catch (err) {
throw new BrunoError(`Error processing ${fileName}: ${err.message}`);
}
};
const processFiles = (parsedFiles) => {
const allEnvironments = [];
for (const parsedFile of parsedFiles) {
try {
const environments = processEnvironmentData(parsedFile.content, parsedFile.fileName);
allEnvironments.push(...environments);
} catch (err) {
throw new BrunoError(`Failed to process ${parsedFile.fileName}: ${err.message}`);
}
}
return allEnvironments;
};
const importBrunoEnvironment = (parsedFiles) => {
try {
if (!parsedFiles || parsedFiles.length === 0) {
throw new BrunoError('No files provided');
}
const environments = processFiles(parsedFiles);
return environments;
} catch (err) {
console.error(err);
throw err instanceof BrunoError ? err : new BrunoError('Import Bruno environment failed');
}
};
export { importBrunoEnvironment, processEnvironmentData };
export default importBrunoEnvironment;

View File

@@ -0,0 +1,41 @@
import { BrunoError } from 'utils/common/error';
const readFile = (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
try {
const parsed = JSON.parse(e.target.result);
resolve({ fileName: file.name, content: parsed });
} catch (err) {
console.error(err);
reject(new BrunoError(`Unable to parse JSON file: ${file.name}`));
}
};
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(file);
});
};
export const readMultipleFiles = async (files) => {
if (!files || files.length === 0) {
throw new BrunoError('No files selected');
}
const parsedFiles = [];
for (const file of files) {
if (!file.name.toLowerCase().endsWith('.json')) {
throw new BrunoError(`Invalid file type: ${file.name}. Only JSON files are supported.`);
}
try {
const parsedFile = await readFile(file);
parsedFiles.push(parsedFile);
} catch (err) {
throw new BrunoError(`Failed to read ${file.name}: ${err.message}`);
}
}
return parsedFiles;
};

View File

@@ -1,45 +1,25 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { postmanToBrunoEnvironment } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
const importEnvironment = async (parsedFiles) => {
try {
const environments = [];
for (const parsedFile of parsedFiles) {
try {
let parsedPostmanEnvironment = JSON.parse(e.target.result);
resolve(parsedPostmanEnvironment);
const environment = postmanToBrunoEnvironment(parsedFile.content);
environments.push(environment);
} catch (err) {
console.error(err);
reject(new BrunoError('Unable to parse the postman environment json file'));
console.error(`Error processing file: ${parsedFile.fileName}`, err);
throw new BrunoError(`Failed to process ${parsedFile.fileName}: ${err.message}`);
}
}
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});
};
const importEnvironment = () => {
return new Promise((resolve, reject) => {
fileDialog({ multiple: true, accept: 'application/json' })
.then((files) => {
return Promise.all(
Object.values(files ?? {}).map((file) =>
readFile([file])
.then((environment) => postmanToBrunoEnvironment(environment))
.catch((err) => {
console.error(`Error processing file: ${file.name || 'undefined'}`, err);
throw err;
})
)
);
})
.then((environments) => resolve(environments))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import Environment failed'));
});
});
return environments;
} catch (err) {
console.log(err);
throw err instanceof BrunoError ? err : new BrunoError('Import Environment failed');
}
};
export default importEnvironment;

View File

@@ -1,6 +1,5 @@
const _ = require('lodash');
const fs = require('fs');
const fsPromises = require('fs/promises');
const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
@@ -14,7 +13,6 @@ const {
stringifyCollection,
parseFolder,
stringifyFolder,
parseEnvironment,
stringifyEnvironment
} = require('@usebruno/filestore');
const brunoConverters = require('@usebruno/converters');
@@ -27,8 +25,6 @@ const {
writeFile,
hasBruExtension,
isDirectory,
browseDirectory,
browseFiles,
createDirectory,
searchForBruFiles,
sanitizeName,
@@ -42,7 +38,8 @@ const {
safeWriteFileSync,
copyPath,
removePath,
getPaths
getPaths,
generateUniqueName
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -298,13 +295,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(envDirPath);
}
const envFilePath = path.join(envDirPath, `${name}.bru`);
if (fs.existsSync(envFilePath)) {
throw new Error(`environment: ${envFilePath} already exists`);
}
// Get existing environment files to generate unique name
const existingFiles = fs.existsSync(envDirPath) ? fs.readdirSync(envDirPath) : [];
const existingEnvNames = existingFiles
.filter((file) => file.endsWith('.bru'))
.map((file) => path.basename(file, '.bru'));
// Generate unique name based on existing environment files
const sanitizedName = sanitizeName(name);
const uniqueName = generateUniqueName(sanitizedName, (name) => existingEnvNames.includes(name));
const envFilePath = path.join(envDirPath, `${uniqueName}.bru`);
const environment = {
name: name,
name: uniqueName,
variables: variables || []
};
@@ -383,6 +387,77 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
// Generic environment export handler
ipcMain.handle('renderer:export-environment', async (event, { environments, environmentType, filePath, exportFormat = 'folder' }) => {
try {
const { app } = require('electron');
const appVersion = app?.getVersion() || '2.0.0';
// For single environments and folder exports, include info in each environment
const environmentWithInfo = (environment) => ({
name: environment.name,
variables: environment.variables,
info: {
type: 'bruno-environment',
exportedAt: new Date().toISOString(),
exportedUsing: `Bruno/v${appVersion}`
}
});
if (exportFormat === 'folder') {
// separate environment json files in folder
const baseFolderName = `bruno-${environmentType}-environments`;
const uniqueFolderName = generateUniqueName(baseFolderName, (name) => fs.existsSync(path.join(filePath, name)));
const exportPath = path.join(filePath, uniqueFolderName);
fs.mkdirSync(exportPath, { recursive: true });
for (const environment of environments) {
const baseFileName = `${environment.name.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
const uniqueFileName = generateUniqueName(baseFileName, (name) => fs.existsSync(path.join(exportPath, `${name}.json`)));
const fullPath = path.join(exportPath, `${uniqueFileName}.json`);
const cleanEnv = environmentWithInfo(environment);
const jsonContent = JSON.stringify(cleanEnv, null, 2);
await fs.promises.writeFile(fullPath, jsonContent, 'utf8');
}
} else if (exportFormat === 'single-file') {
// all environments in a single file with top-level info and environments array
const baseFileName = `bruno-${environmentType}-environments`;
const uniqueFileName = generateUniqueName(baseFileName, (name) => fs.existsSync(path.join(filePath, `${name}.json`)));
const fullPath = path.join(filePath, `${uniqueFileName}.json`);
const exportData = {
info: {
type: 'bruno-environment',
exportedAt: new Date().toISOString(),
exportedUsing: `Bruno/v${appVersion}`
},
environments
};
const jsonContent = JSON.stringify(exportData, null, 2);
await fs.promises.writeFile(fullPath, jsonContent, 'utf8');
} else if (exportFormat === 'single-object') {
// single environment json file
if (environments.length !== 1) {
throw new Error('Single object export requires exactly one environment');
}
const environment = environments[0];
const baseFileName = `${environment.name.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
const uniqueFileName = generateUniqueName(baseFileName, (name) => fs.existsSync(path.join(filePath, `${name}.json`)));
const fullPath = path.join(filePath, `${uniqueFileName}.json`);
const jsonContent = JSON.stringify(environmentWithInfo(environment), null, 2);
await fs.promises.writeFile(fullPath, jsonContent, 'utf8');
} else {
throw new Error(`Unsupported export format: ${exportFormat}`);
}
} catch (error) {
return Promise.reject(error);
}
});
// rename item
ipcMain.handle('renderer:rename-item-name', async (event, { itemPath, newName }) => {
try {

View File

@@ -50,4 +50,4 @@ const registerFilesystemIpc = (mainWindow) => {
});
};
module.exports = registerFilesystemIpc;
module.exports = registerFilesystemIpc;

View File

@@ -1,6 +1,7 @@
require('dotenv').config();
const { ipcMain } = require('electron');
const { globalEnvironmentsStore } = require('../store/global-environments');
const { generateUniqueName, sanitizeName } = require('../utils/filesystem');
const registerGlobalEnvironmentsIpc = (mainWindow) => {
@@ -8,7 +9,18 @@ const registerGlobalEnvironmentsIpc = (mainWindow) => {
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => {
try {
globalEnvironmentsStore.addGlobalEnvironment({ uid, name, variables });
// Get existing global environment names to generate unique name
const existingGlobalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
const existingNames = existingGlobalEnvironments?.map((env) => env.name) || [];
// Generate unique name based on existing global environment names
const sanitizedName = sanitizeName(name);
const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name));
globalEnvironmentsStore.addGlobalEnvironment({ uid, name: uniqueName, variables });
// Return the unique name that was actually used
return { name: uniqueName };
} catch (error) {
return Promise.reject(error);
}

View File

@@ -172,7 +172,29 @@ const sanitizeName = (name) => {
const isWindowsOS = () => {
return os.platform() === 'win32';
}
};
/**
* Generate a unique name by adding a "copy" suffix if needed
*
* @param {string} baseName - The base name
* @param {Function} checkExists - Function that takes a name and returns true if it exists
* @returns {string} - A unique name
*/
const generateUniqueName = (baseName, checkExists) => {
if (!checkExists(baseName)) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName} copy`;
while (checkExists(uniqueName)) {
counter++;
uniqueName = `${baseName} copy ${counter}`;
}
return uniqueName;
};
const validateName = (name) => {
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // keeping this for informational purpose
@@ -389,5 +411,6 @@ module.exports = {
copyPath,
removePath,
getPaths,
isLargeFile
isLargeFile,
generateUniqueName
};

View File

@@ -0,0 +1,405 @@
import { test, expect } from '../../../../playwright';
import path from 'path';
import fs from 'fs';
// Helper function to load expected fixtures
function loadExpectedFixture(fixturePath: string) {
const fullPath = path.join(__dirname, '..', '../fixtures', 'environment-exports', fixturePath);
console.log(fullPath);
return JSON.parse(fs.readFileSync(fullPath, 'utf8'));
}
// Helper function to normalize dynamic fields for comparison
function normalizeExportedContent(content: any) {
if (content.info) {
// Replace dynamic fields with fixed values for comparison
content.info.exportedAt = '2024-01-01T00:00:00.000Z';
content.info.exportedUsing = 'Bruno/v1.0.0';
}
return content;
}
test.describe.serial('Collection Environment Export Tests', () => {
test.describe.serial('folder exports', () => {
test('should export single collection environment', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-single');
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the environment settings modal opens
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
});
await test.step('Open export modal and configure export settings', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Verify export modal opens
const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' });
await expect(exportModal).toBeVisible();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'local.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-collection-environments/local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
test('should export multiple collection environments', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-multiple');
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for multiple environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Verify all environments are selected by default
await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Prod' })).toBeChecked();
// Select folder export format (default might be single JSON file)
await page.getByText('Separate files in folder').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export all environments
await page.getByRole('button', { name: /Export \d+ Environments?/ }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported files and content', async () => {
// Verify exported files exist
const exportPath = path.join(exportDir, 'bruno-collection-environments');
expect(fs.existsSync(exportPath)).toBe(true);
const expectedFiles = [
'local.json',
'prod.json'
];
for (const fileName of expectedFiles) {
const filePath = path.join(exportPath, fileName);
expect(fs.existsSync(filePath)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const expectedContent = loadExpectedFixture(`bruno-collection-environments/${fileName}`);
expect(normalizeExportedContent(content)).toEqual(expectedContent);
}
});
});
test('should generate unique names when the export directory already contains previously exported contents', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-conflict');
await test.step('Setup existing export directory to simulate conflict', async () => {
// Create existing export directory and file to simulate conflict
const existingExportPath = path.join(exportDir, 'bruno-collection-environments');
fs.mkdirSync(existingExportPath, { recursive: true });
fs.writeFileSync(path.join(existingExportPath, 'local.json'), '{}');
});
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings with folder format', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Select folder export format (default might be single JSON file)
await page.getByText('Separate files in folder').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 2 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify unique naming and file content', async () => {
// Verify original folder still exists
const existingExportPath = path.join(exportDir, 'bruno-collection-environments');
expect(fs.existsSync(existingExportPath)).toBe(true);
expect(fs.existsSync(path.join(existingExportPath, 'local.json'))).toBe(true);
// Verify new folder with unique name was created
const uniqueExportPath = path.join(exportDir, 'bruno-collection-environments copy');
expect(fs.existsSync(uniqueExportPath)).toBe(true);
// Verify the new file exists in the unique folder
const newExportedFile = path.join(uniqueExportPath, 'local.json');
expect(fs.existsSync(newExportedFile)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(newExportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-collection-environments/local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
});
test.describe.serial('json file exports', () => {
test('should export single collection environment as object', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-single-object');
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open collection environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and verify success', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'local.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('local.json');
expect(normalizeExportedContent(content)).toEqual(expectedContent);
});
});
test('should export multiple collection environments as single JSON file', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-single-file');
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open collection environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Select single JSON file format
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and verify success', async () => {
// Export the environments
await page.getByRole('button', { name: 'Export 2 Environments' }).click();
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false }).first()).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'bruno-collection-environments.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-collection-environments.json');
expect(normalizeExportedContent(content)).toEqual(expectedContent);
});
});
test('should generate unique names when the export directory already contains previously exported contents', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-single-object');
await test.step('Setup existing export file to simulate conflict', async () => {
// Create existing export directory and file to simulate conflict
const existingExportJsonPath = path.join(exportDir, 'local.json');
fs.writeFileSync(existingExportJsonPath, '{}');
});
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open collection environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify unique naming and file content', async () => {
// Verify original file still exists
const existingExportJsonPath = path.join(exportDir, 'local.json');
expect(fs.existsSync(existingExportJsonPath)).toBe(true);
// Verify new file with unique name was created
const uniqueExportPath = path.join(exportDir, 'local copy.json');
expect(fs.existsSync(uniqueExportPath)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(uniqueExportPath, 'utf8'));
const expectedContent = loadExpectedFixture('local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
});
// common tests
test('should not be able to export, when no environments are selected', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('collection-env-export-no-selection');
await test.step('Open collection and navigate to environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Open export modal and deselect all environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments
await page.getByText('Deselect All').click();
});
await test.step('Verify export button is disabled when no environments selected', async () => {
// Verify export button is disabled
await expect(page.getByRole('button', { name: 'Export Environments' })).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Environment Export Test Collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,7 @@
vars {
host: http://localhost:3000
}
vars:secret [
secretToken
]

View File

@@ -0,0 +1,7 @@
vars {
host: https://echo.usebruno.com
}
vars:secret [
secretToken
]

View File

@@ -0,0 +1,9 @@
meta {
name: Test Request
type: http
seq: 1
}
get {
url: {{host}}
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/environments/export-environment/collection-env-export/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,49 @@
{
"environments": [
{
"uid": "local-env-export-test",
"name": "local",
"variables": [
{
"uid": "local-env-export-test-host",
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"uid": "local-env-export-test-secret-token",
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
},
{
"uid": "prod-env-export-test",
"name": "prod",
"variables": [
{
"uid": "prod-env-export-test-host",
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"uid": "prod-env-export-test-secret-token",
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
}
],
"activeGlobalEnvironmentUid": "local-env-export-test"
}

View File

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

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Environment Export Test Collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,9 @@
meta {
name: Test Request
type: http
seq: 1
}
get {
url: {{host}}
}

View File

@@ -0,0 +1,413 @@
import { test, expect } from '../../../../playwright';
import path from 'path';
import fs from 'fs';
// Helper function to load expected fixtures
function loadExpectedFixture(fixturePath: string) {
const fullPath = path.join(__dirname, '..', '../fixtures', 'environment-exports', fixturePath);
return JSON.parse(fs.readFileSync(fullPath, 'utf8'));
}
// Helper function to normalize dynamic fields for comparison
function normalizeExportedContent(content: any) {
if (content.info) {
// Replace dynamic fields with fixed values for comparison
content.info.exportedAt = '2024-01-01T00:00:00.000Z';
content.info.exportedUsing = 'Bruno/v1.0.0';
}
// Handle individual environment files that have info at the root level
if (content.name && content.variables && content.info) {
content.info.exportedAt = '2024-01-01T00:00:00.000Z';
content.info.exportedUsing = 'Bruno/v1.0.0';
}
return content;
}
test.describe.serial('Global Environment Export Tests', () => {
test.describe.serial('folder exports', () => {
test('should export single global environment', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-single');
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
// Verify the global environment settings modal opens
const globalEnvModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(globalEnvModal).toBeVisible();
});
await test.step('Open export modal and configure export settings', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Verify export modal opens
const exportModal = page.locator('.bruno-modal').filter({ hasText: 'Export Environments' });
await expect(exportModal).toBeVisible();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'local.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-global-environments/local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
test('should export multiple global environments', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-multiple');
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for multiple environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Verify all environments are selected by default
await expect(page.getByRole('checkbox', { name: 'Local' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Prod' })).toBeChecked();
// Select folder export format (default might be single JSON file)
await page.getByText('Separate files in folder').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export all environments
await page.getByRole('button', { name: /Export \d+ Environments?/ }).click();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported files and content', async () => {
// Verify exported files exist
const exportPath = path.join(exportDir, 'bruno-global-environments');
expect(fs.existsSync(exportPath)).toBe(true);
const expectedFiles = [
'local.json',
'prod.json'
];
for (const fileName of expectedFiles) {
const filePath = path.join(exportPath, fileName);
expect(fs.existsSync(filePath)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const expectedContent = loadExpectedFixture(`bruno-global-environments/${fileName}`);
expect(normalizeExportedContent(content)).toEqual(expectedContent);
}
});
});
test('should generate unique names when the export directory already contains previously exported contents', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-conflict');
await test.step('Setup existing export directory to simulate conflict', async () => {
// Create existing export directory and file to simulate conflict
const existingExportPath = path.join(exportDir, 'bruno-global-environments');
fs.mkdirSync(existingExportPath, { recursive: true });
fs.writeFileSync(path.join(existingExportPath, 'local.json'), '{}');
});
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings with folder format', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
// Select folder export format
await page.getByText('Separate files in folder').click();
});
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 2 Environment' }).click();
await page.getByTestId('modal-close-button').first().click();
});
await test.step('Verify unique naming and file content', async () => {
// Verify original folder still exists
const existingExportPath = path.join(exportDir, 'bruno-global-environments');
expect(fs.existsSync(existingExportPath)).toBe(true);
expect(fs.existsSync(path.join(existingExportPath, 'local.json'))).toBe(true);
// Verify new folder with unique name was created
const uniqueExportPath = path.join(exportDir, 'bruno-global-environments copy');
expect(fs.existsSync(uniqueExportPath)).toBe(true);
// Verify the new file exists in the unique folder
const newExportedFile = path.join(uniqueExportPath, 'local.json');
expect(fs.existsSync(newExportedFile)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(newExportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-global-environments/local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
});
test.describe.serial('json file exports', () => {
test('should export single global environment as object', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-single-object');
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
// Single JSON file format is automatically selected for single environment
// The backend will automatically use 'single-object' format for single environment
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and verify success', async () => {
// Export the environment
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false })).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'local.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('local.json');
expect(normalizeExportedContent(content)).toEqual(expectedContent);
});
});
test('should export multiple global environments as single JSON file', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-single-file');
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Select single JSON file format
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and verify success', async () => {
// Export the environments
await page.getByRole('button', { name: 'Export 2 Environments' }).click();
// Verify success message
await expect(page.getByText('Environment(s) exported successfully', { exact: false })).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
await test.step('Verify exported file and content', async () => {
// Verify exported file exists
const exportedFile = path.join(exportDir, 'bruno-global-environments.json');
expect(fs.existsSync(exportedFile)).toBe(true);
// Verify file content matches expected fixture
const content = JSON.parse(fs.readFileSync(exportedFile, 'utf8'));
const expectedContent = loadExpectedFixture('bruno-global-environments.json');
expect(normalizeExportedContent(content)).toEqual(expectedContent);
});
});
test('should generate unique names when the export directory already contains previously exported contents', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-single-object-conflict');
await test.step('Setup existing export file to simulate conflict', async () => {
// Create existing export directory and file to simulate conflict
const existingExportJsonPath = path.join(exportDir, 'local.json');
fs.writeFileSync(existingExportJsonPath, '{}');
});
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Configure export settings for single JSON file', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments first
await page.getByText('Deselect All').click();
// Select only "local" environment
const localEnvCheckbox = page.locator('label').filter({ hasText: 'local' }).locator('input[type="checkbox"]');
await localEnvCheckbox.check();
// Single JSON file format is automatically selected for single environment
// The backend will automatically use 'single-object' format for single environment
await page.getByText('Single JSON file').click();
// Set export directory
await page.locator('input[id="export-location"]').fill(exportDir);
});
await test.step('Execute export and close modal', async () => {
// Export should succeed with unique names
await page.getByRole('button', { name: 'Export 1 Environment' }).click();
await page.getByTestId('modal-close-button').first().click();
});
await test.step('Verify unique naming and file content', async () => {
// Verify original file still exists
const existingExportJsonPath = path.join(exportDir, 'local.json');
expect(fs.existsSync(existingExportJsonPath)).toBe(true);
// Verify new file with unique name was created
const uniqueExportPath = path.join(exportDir, 'local copy.json');
expect(fs.existsSync(uniqueExportPath)).toBe(true);
// Verify file content matches expected fixture
const exportedContent = JSON.parse(fs.readFileSync(uniqueExportPath, 'utf8'));
const expectedContent = loadExpectedFixture('local.json');
expect(normalizeExportedContent(exportedContent)).toEqual(expectedContent);
});
});
});
// common tests
test('should not be able to export, when no environments are selected', async ({
pageWithUserData: page,
createTmpDir
}) => {
const exportDir = await createTmpDir('global-env-export-no-selection');
await test.step('Open collection and navigate to global environment settings', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Export Test Collection' }).click();
// Open global environment settings
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
});
await test.step('Open export modal and deselect all environments', async () => {
// Click export button
await page.locator('.btn-import-environment').getByText('Export').click();
// Deselect all environments
await page.getByText('Deselect All').click();
});
await test.step('Verify export button is disabled when no environments selected', async () => {
// Verify export button is disabled
await expect(page.getByRole('button', { name: 'Export Environments' })).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/environments/export-environment/global-env-export/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,49 @@
{
"environments": [
{
"uid": "local-env-export-test",
"name": "local",
"variables": [
{
"uid": "local-env-export-test-host",
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"uid": "local-env-export-test-secret-token",
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
},
{
"uid": "prod-env-export-test",
"name": "prod",
"variables": [
{
"uid": "prod-env-export-test-host",
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"uid": "prod-env-export-test-secret-token",
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
}
],
"activeGlobalEnvironmentUid": "local-env-export-test"
}

View File

@@ -0,0 +1,6 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/environments/export-environment/global-env-export/fixtures/collection"
]
}

View File

@@ -0,0 +1,47 @@
{
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
},
"environments": [
{
"name": "local",
"variables": [
{
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
},
{
"name": "prod",
"variables": [
{
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"name": "local",
"variables": [
{
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
],
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "prod",
"variables": [
{
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
],
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
}
}

View File

@@ -0,0 +1,47 @@
{
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
},
"environments": [
{
"name": "local",
"variables": [
{
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
},
{
"name": "prod",
"variables": [
{
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"name": "local",
"variables": [
{
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
],
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "prod",
"variables": [
{
"name": "host",
"value": "https://echo.usebruno.com",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
],
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "local",
"variables": [
{
"name": "host",
"value": "http://localhost:3000",
"type": "text",
"enabled": true,
"secret": false
},
{
"name": "secretToken",
"value": "",
"type": "text",
"enabled": true,
"secret": true
}
],
"info": {
"type": "bruno-environment",
"exportedAt": "2024-01-01T00:00:00.000Z",
"exportedUsing": "Bruno/v1.0.0"
}
}

View File

@@ -0,0 +1,137 @@
import { test, expect } from '../../../../../playwright';
import path from 'path';
import fs from 'fs';
test.describe.serial('Collection Environment Import Tests', () => {
test('should import single collection environment', async ({ pageWithUserData: page }) => {
const singleEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/local.json');
const collectionPath = path.join(__dirname, 'fixtures/collection');
const environmentsPath = path.join(collectionPath, 'environments');
await test.step('Clean up existing environments and open collection', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
});
await test.step('Navigate to collection environment import', async () => {
// Open environment import
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await expect(page.getByTestId('env-tab-collection')).toHaveClass(/active/);
await page.getByText('Import', { exact: true }).click();
// Verify import modal opens
const importModal = page.locator('[data-testid="import-environment-modal"]');
await expect(importModal).toBeVisible();
});
await test.step('Import environment file', async () => {
// Import environment file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByTestId('import-environment').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(singleEnvFile);
});
await test.step('Verify imported environment and variables', async () => {
// The environment settings modal should now be visible with the imported environment
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
});
await test.step('Clean up after test', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
});
});
test('should import multiple collection environments', async ({ pageWithUserData: page }) => {
const multiEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/bruno-collection-environments.json');
const collectionPath = path.join(__dirname, 'fixtures/collection');
const environmentsPath = path.join(collectionPath, 'environments');
await test.step('Clean up existing environments and open collection', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
});
await test.step('Navigate to collection environment import', async () => {
// Open environment import
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-collection').click();
await expect(page.getByTestId('env-tab-collection')).toHaveClass(/active/);
await page.getByText('Import', { exact: true }).click();
// Verify import modal opens
const importModal = page.locator('[data-testid="import-environment-modal"]');
await expect(importModal).toBeVisible();
});
await test.step('Import multiple environments file', async () => {
// Import environment file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByTestId('import-environment').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(multiEnvFile);
// The environment settings modal should now be visible with the imported environments
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
});
await test.step('Verify both environments are available in selector', async () => {
// Check that both environments are available in the selector
await page.getByText('×').click(); // Close environment settings modal
await page.getByTestId('environment-selector-trigger').click();
// Verify both environments are in the dropdown
await expect(page.locator('.dropdown-item').filter({ hasText: /^local$/ })).toBeVisible();
await expect(page.locator('.dropdown-item').filter({ hasText: /^prod$/ })).toBeVisible();
});
await test.step('Test switching to prod environment and verify variables', async () => {
// Test switching to prod environment
await page.locator('.dropdown-item').filter({ hasText: 'prod' }).click();
await expect(page.locator('.current-environment')).toContainText('prod');
// Verify prod environment variables by opening settings again
await page.getByTestId('environment-selector-trigger').click();
await page.getByText('Configure', { exact: true }).click();
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Environments' });
await expect(envModal).toBeVisible();
// Verify prod environment variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
});
await test.step('Clean up after test', async () => {
// Clean up any existing environments folder before test
if (fs.existsSync(environmentsPath)) {
fs.rmSync(environmentsPath, { recursive: true, force: true });
}
});
});
});

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Environment Import Test Collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,9 @@
meta {
name: Test Request
type: http
seq: 1
}
get {
url: {{host}}
}

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/environments/import-environment/bruno-env-import/collection-env-import/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

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

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "Environment Import Test Collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,9 @@
meta {
name: Test Request
type: http
seq: 1
}
get {
url: {{host}}
}

View File

@@ -0,0 +1,159 @@
import { test, expect } from '../../../../../playwright';
import path from 'path';
test.describe.serial('Global Environment Import Tests', () => {
test('should import single global environment', async ({ pageWithUserData: page }) => {
const singleEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/local.json');
await test.step('Open collection and clean up existing global environments', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
// Clean up any existing global environments before test
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
// Check if there are existing environments to delete
const existingEnvs = page.locator('.dropdown-item').filter({ hasText: /^(local|prod)$/ });
const count = await existingEnvs.count();
if (count > 0) {
// Open global environment settings to delete existing environments
await page.getByText('Configure', { exact: true }).click();
// Delete all existing environments
for (let i = 0; i < count; i++) {
await page.getByTestId('delete-environment-button').click();
// Confirm deletion if there's a confirmation dialog
const confirmButton = page.getByRole('button', { name: 'Delete' });
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.getByText('×').click();
}
}
// Clean up any existing global environments before test
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
}
});
await test.step('Navigate to global environment import', async () => {
await expect(page.getByTestId('env-tab-global')).toHaveClass(/active/);
await page.getByRole('button', { name: 'Import', exact: true }).click();
// Verify import modal opens
const importModal = page.locator('[data-testid="import-global-environment-modal"]');
await expect(importModal).toBeVisible();
});
await test.step('Import global environment file', async () => {
// Import environment file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('[data-testid="import-global-environment"]').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(singleEnvFile);
});
await test.step('Verify imported global environment and variables', async () => {
// The global environment settings modal should now be visible with the imported environment
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
});
});
test('should import multiple global environments', async ({ pageWithUserData: page }) => {
const multiEnvFile = path.join(__dirname, '../../../fixtures/environment-exports/bruno-global-environments.json');
await test.step('Open collection and clean up existing global environments', async () => {
// Open the collection from sidebar
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Import Test Collection' }).click();
// Clean up any existing global environments before test
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
// Check if there are existing environments to delete
const existingEnvs = page.locator('.dropdown-item').filter({ hasText: /^(local|prod)$/ });
const count = await existingEnvs.count();
if (count > 0) {
// Open global environment settings to delete existing environments
await page.getByText('Configure', { exact: true }).click();
// Delete all existing environments
for (let i = 0; i < count; i++) {
await page.getByTestId('delete-environment-button').click();
// Confirm deletion if there's a confirmation dialog
const confirmButton = page.getByRole('button', { name: 'Delete' });
if (await confirmButton.isVisible()) {
await confirmButton.click();
await page.getByText('×').click();
}
}
// Clean up any existing global environments before test
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
}
});
await test.step('Navigate to global environment import', async () => {
await page.getByText('Import', { exact: true }).click();
// Verify import modal opens
const importModal = page.locator('[data-testid="import-global-environment-modal"]');
await expect(importModal).toBeVisible();
});
await test.step('Import multiple global environments file', async () => {
// Import environment file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('[data-testid="import-global-environment"]').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(multiEnvFile);
// The global environment settings modal should now be visible with the imported environments
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
});
await test.step('Verify both global environments are available in selector', async () => {
// Check that both environments are available in the selector
await page.getByText('×').click(); // Close environment settings modal
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
// Verify both environments are in the dropdown
await expect(page.locator('.dropdown-item').filter({ hasText: /^local$/ })).toBeVisible();
await expect(page.locator('.dropdown-item').filter({ hasText: /^prod$/ })).toBeVisible();
});
await test.step('Test switching to prod environment and verify variables', async () => {
// Test switching to prod environment
await page.locator('.dropdown-item').filter({ hasText: 'prod' }).click();
await expect(page.locator('.current-environment')).toContainText('prod');
// Verify prod environment variables by opening settings again
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Configure', { exact: true }).click();
const envModal = page.locator('.bruno-modal').filter({ hasText: 'Global Environments' });
await expect(envModal).toBeVisible();
// Verify imported variables
await expect(page.getByRole('row', { name: 'host' }).getByRole('cell').nth(1)).toBeVisible();
await expect(page.getByRole('row', { name: 'secretToken' }).getByRole('cell').nth(1)).toBeVisible();
// Close modal
await page.getByText('×').click();
});
});
});

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/environments/import-environment/bruno-env-import/global-env-import/fixtures/collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

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

View File

@@ -40,7 +40,7 @@ test.describe('Collection Environment Import Tests', () => {
// Import environment file
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('button[data-testid="import-postman-environment"]').click();
await page.getByTestId('import-environment').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(envFile);

View File

@@ -2,7 +2,7 @@ import { test, expect } from '../../../playwright';
import path from 'path';
test.describe('Global Environment Import Tests', () => {
test('should import global environment from file', async ({ page, createTmpDir }) => {
test('should import global environment from file', async ({ newPage: page, createTmpDir }) => {
const openApiFile = path.join(__dirname, 'fixtures', 'collection.json');
const globalEnvFile = path.join(__dirname, 'fixtures', 'global-env.json');
@@ -32,16 +32,15 @@ test.describe('Global Environment Import Tests', () => {
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();
await page.getByTestId('environment-selector-trigger').click();
await page.getByTestId('env-tab-global').click();
await page.getByText('Import', { exact: true }).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();
await page.locator('[data-testid="import-global-environment"]').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(globalEnvFile);