mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 21:55:49 +00:00
feat: implement collection migration to YML format (#7669)
This commit is contained in:
@@ -8,6 +8,7 @@ import ShareCollection from 'components/ShareCollection/index';
|
||||
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Migration from '../Migration';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -126,6 +127,8 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
|
||||
|
||||
<Migration collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.migration-section {
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-box.migration {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.08)};
|
||||
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.09)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
.backup-section {
|
||||
border: 1px solid ${(props) => props.theme.border.border2};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.backup-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.backup-section-title {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.backup-section-help {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
line-height: 1.45;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.backup-section-action {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconTransform } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Migration = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Only show for bru format collections
|
||||
if (collection.format !== 'bru') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleMigrate = () => {
|
||||
setIsMigrating(true);
|
||||
setShowConfirmModal(false);
|
||||
dispatch(migrateCollectionToYml(collection.uid))
|
||||
.catch(() => { })
|
||||
.finally(() => setIsMigrating(false));
|
||||
};
|
||||
|
||||
const handleExportBackup = async () => {
|
||||
if (isExporting) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
|
||||
if (result?.success) {
|
||||
toast.success('Collection backup exported');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to export backup: ' + error.message);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="migration-section">
|
||||
<div className="text-lg font-medium flex items-center gap-2 mb-4">
|
||||
<IconTransform size={20} stroke={1.5} />
|
||||
Migration
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="icon-box migration flex-shrink-0 p-3 rounded-lg">
|
||||
<IconFileCode className="w-5 h-5" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-medium">Migrate to YML file format</div>
|
||||
<div className="my-1 text-muted text-sm">
|
||||
This collection is stored in BRU format.{' '}
|
||||
Switch to YML.{' '}
|
||||
<a
|
||||
href="https://blog.usebruno.com/making-yaml-the-default-in-bruno-v3.1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:underline"
|
||||
>
|
||||
Learn More ↗
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="migrate-collection-to-yml-button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="mt-2"
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={isMigrating}
|
||||
loading={isMigrating}
|
||||
>
|
||||
Convert to YML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmModal && (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Migrate to YML format"
|
||||
confirmText="Migrate"
|
||||
confirmDisabled={isExporting}
|
||||
handleConfirm={handleMigrate}
|
||||
handleCancel={() => setShowConfirmModal(false)}
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
This will convert all files in <strong>{collection.name}</strong> from <code>.bru</code> format to <code>.yml</code> format.
|
||||
</p>
|
||||
<div className="mt-4 text-sm text-muted">
|
||||
<p className="font-medium mb-2">What will happen:</p>
|
||||
<ul className="list-disc ml-5 flex flex-col gap-1">
|
||||
<li>All <code>.bru</code> request files will be converted to <code>.yml</code></li>
|
||||
<li>Environment files will be converted to YML format</li>
|
||||
<li><code>bruno.json</code> will be replaced with <code>opencollection.yml</code></li>
|
||||
<li>The collection will be reloaded after migration</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="backup-section mt-4">
|
||||
<div className="backup-section-head">
|
||||
<span className="backup-section-title">Backup</span>
|
||||
</div>
|
||||
<p className="backup-section-help">
|
||||
Export this collection as a ZIP archive before migrating, in case you want to restore it later.
|
||||
</p>
|
||||
<div className="backup-section-action">
|
||||
<Button
|
||||
data-testid="export-collection-backup-button"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
onClick={handleExportBackup}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? 'Exporting…' : 'Export Collection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Migration;
|
||||
@@ -151,6 +151,62 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.migrate-yml-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
.pill-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pill-dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.display-icon{
|
||||
padding: 4px;
|
||||
box-sizing: content-box;
|
||||
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
IconFolder,
|
||||
IconUpload,
|
||||
IconFileCode,
|
||||
IconFileOff
|
||||
IconFileOff,
|
||||
IconTransform
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { toggleCollectionFileMode, updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -38,6 +39,19 @@ import classNames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const MIGRATE_PILL_DISMISSED_KEY = 'bruno.migrateToYmlPill.dismissed';
|
||||
|
||||
const readDismissedCollections = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(MIGRATE_PILL_DISMISSED_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces.workspaces);
|
||||
@@ -56,6 +70,27 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
// Migrate-to-YML pill dismissal state (persisted by collection pathname)
|
||||
const [migratePillDismissed, setMigratePillDismissed] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!collection?.pathname) return;
|
||||
const dismissed = readDismissedCollections();
|
||||
setMigratePillDismissed(dismissed.includes(collection.pathname));
|
||||
}, [collection?.pathname]);
|
||||
|
||||
const dismissMigratePill = (e) => {
|
||||
e?.stopPropagation();
|
||||
if (!collection?.pathname) return;
|
||||
const dismissed = readDismissedCollections();
|
||||
if (!dismissed.includes(collection.pathname)) {
|
||||
dismissed.push(collection.pathname);
|
||||
try {
|
||||
localStorage.setItem(MIGRATE_PILL_DISMISSED_KEY, JSON.stringify(dismissed));
|
||||
} catch { }
|
||||
}
|
||||
setMigratePillDismissed(true);
|
||||
};
|
||||
|
||||
const switcherRef = useRef();
|
||||
const workspaceActionsRef = useRef();
|
||||
const workspaceNameInputRef = useRef(null);
|
||||
@@ -212,6 +247,17 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const viewMigrationSettings = () => {
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
})
|
||||
);
|
||||
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'overview' }));
|
||||
};
|
||||
|
||||
const viewOpenApiSync = () => {
|
||||
dispatch(addTab({
|
||||
uid: uuid(),
|
||||
@@ -584,6 +630,31 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
{/* Right side: Actions (only for regular collections) */}
|
||||
{!isScratchCollection && (
|
||||
<div className="flex flex-grow gap-1.5 items-center justify-end">
|
||||
{collection.format === 'bru' && !migratePillDismissed && (
|
||||
<div
|
||||
className="migrate-yml-pill"
|
||||
data-testid="migrate-yml-pill"
|
||||
title="Migrate this collection to YML"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pill-main"
|
||||
onClick={viewMigrationSettings}
|
||||
>
|
||||
<IconTransform size={13} strokeWidth={1.5} />
|
||||
<span className="pill-label">Migrate to YML</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pill-dismiss"
|
||||
onClick={dismissMigratePill}
|
||||
aria-label="Dismiss"
|
||||
data-testid="migrate-yml-pill-dismiss"
|
||||
>
|
||||
<IconX size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
|
||||
{hasOpenApiSyncConfigured && (
|
||||
<ToolHint
|
||||
|
||||
@@ -62,7 +62,8 @@ import {
|
||||
updateCollectionVar,
|
||||
addTransientDirectory,
|
||||
addSaveTransientRequestModal,
|
||||
updatePathParam
|
||||
updatePathParam,
|
||||
toggleCollection
|
||||
} from './index';
|
||||
|
||||
import { each } from 'lodash';
|
||||
@@ -3375,3 +3376,73 @@ export const reopenClosedTab = ({ collectionUid } = {}) => async (dispatch) => {
|
||||
dispatch(reopenLastClosedTab({ collectionUid }));
|
||||
await dispatch(ensureActiveTabInCurrentWorkspace());
|
||||
};
|
||||
|
||||
export const migrateCollectionToYml = (collectionUid) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
const collectionPathname = collection.pathname;
|
||||
const uid = collection.uid;
|
||||
ipcRenderer
|
||||
.invoke('renderer:migrate-collection-to-yml', collectionPathname, collectionUid)
|
||||
.then(async (updatedBrunoConfig) => {
|
||||
// Remove old collection state so we can recreate it with the new yml config.
|
||||
// openCollectionEvent requires no existing collection with the same pathname.
|
||||
dispatch(_removeCollection({ collectionUid }));
|
||||
dispatch(closeAllCollectionTabs({ collectionUid }));
|
||||
|
||||
try {
|
||||
// Reopen the collection with updated config (now yml format)
|
||||
await dispatch(openCollectionEvent(uid, collectionPathname, updatedBrunoConfig));
|
||||
|
||||
// Mount the collection (starts the watcher and loads items)
|
||||
await dispatch(mountCollection({
|
||||
collectionUid: uid,
|
||||
collectionPathname: collectionPathname,
|
||||
brunoConfig: updatedBrunoConfig
|
||||
}));
|
||||
} catch (reopenError) {
|
||||
// Files on disk are already yml; best-effort recovery so the
|
||||
// collection doesn't disappear from the UI. openCollectionEvent is
|
||||
// a no-op if it already succeeded, and mountCollection is what we
|
||||
// retry when it was the failing step.
|
||||
try {
|
||||
await dispatch(openCollectionEvent(uid, collectionPathname, updatedBrunoConfig));
|
||||
await dispatch(mountCollection({
|
||||
collectionUid: uid,
|
||||
collectionPathname: collectionPathname,
|
||||
brunoConfig: updatedBrunoConfig
|
||||
}));
|
||||
} catch (_) {}
|
||||
throw reopenError;
|
||||
}
|
||||
|
||||
// Expand the collection in the sidebar (only if collapsed)
|
||||
const reopenedCollection = findCollectionByUid(getState().collections.collections, uid);
|
||||
if (reopenedCollection?.collapsed) {
|
||||
dispatch(toggleCollection(uid));
|
||||
}
|
||||
|
||||
// Reopen collection settings on the overview tab
|
||||
dispatch(addTab({
|
||||
uid: uid,
|
||||
collectionUid: uid,
|
||||
type: 'collection-settings'
|
||||
}));
|
||||
dispatch(updateSettingsSelectedTab({ collectionUid: uid, tab: 'overview' }));
|
||||
|
||||
toast.success('Collection migrated to YML format successfully');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(`Migration failed: ${err.message || 'Unknown error'}`);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ const {
|
||||
isWindowsOS,
|
||||
hasRequestExtension,
|
||||
getCollectionFormat,
|
||||
searchForFiles,
|
||||
searchForRequestFiles,
|
||||
validateName,
|
||||
getCollectionStats,
|
||||
@@ -2624,6 +2625,124 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:migrate-collection-to-yml', async (event, collectionPathname, collectionUid) => {
|
||||
const format = getCollectionFormat(collectionPathname);
|
||||
if (format === 'yml') {
|
||||
throw new Error('Collection is already in YML format');
|
||||
}
|
||||
|
||||
// Stop the watcher during migration to avoid triggering events
|
||||
if (watcher) {
|
||||
watcher.removeWatcher(collectionPathname, mainWindow, collectionUid);
|
||||
}
|
||||
|
||||
// Track all written yml files so we can roll back on failure
|
||||
const writtenYmlFiles = [];
|
||||
|
||||
try {
|
||||
const brunoJsonPath = path.join(collectionPathname, 'bruno.json');
|
||||
const brunoJsonContent = fs.readFileSync(brunoJsonPath, 'utf8');
|
||||
const brunoConfig = JSON.parse(brunoJsonContent);
|
||||
|
||||
const collectionBruPath = path.join(collectionPathname, 'collection.bru');
|
||||
let collectionRoot = {};
|
||||
if (fs.existsSync(collectionBruPath)) {
|
||||
const collectionBruContent = fs.readFileSync(collectionBruPath, 'utf8');
|
||||
collectionRoot = parseCollection(collectionBruContent, { format: 'bru' });
|
||||
}
|
||||
|
||||
const ymlBrunoConfig = { ...brunoConfig };
|
||||
delete ymlBrunoConfig.version;
|
||||
ymlBrunoConfig.opencollection = '1.0.0';
|
||||
|
||||
const ocYmlPath = path.join(collectionPathname, 'opencollection.yml');
|
||||
const ymlCollectionContent = stringifyCollection(collectionRoot, ymlBrunoConfig, { format: 'yml' });
|
||||
await writeFile(ocYmlPath, ymlCollectionContent);
|
||||
writtenYmlFiles.push(ocYmlPath);
|
||||
|
||||
const bruFiles = searchForFiles(collectionPathname, '.bru');
|
||||
const envDirPath = path.join(collectionPathname, 'environments');
|
||||
const bruFilesToDelete = [];
|
||||
|
||||
for (const bruFilePath of bruFiles) {
|
||||
const basename = path.basename(bruFilePath);
|
||||
const dirname = path.dirname(bruFilePath);
|
||||
|
||||
if (basename === 'collection.bru' && path.normalize(dirname) === path.normalize(collectionPathname)) {
|
||||
bruFilesToDelete.push(bruFilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (path.normalize(dirname) === path.normalize(envDirPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (basename === 'folder.bru') {
|
||||
const folderBruContent = fs.readFileSync(bruFilePath, 'utf8');
|
||||
const folderData = parseFolder(folderBruContent, { format: 'bru' });
|
||||
const ymlContent = stringifyFolder(folderData, { format: 'yml' });
|
||||
const ymlFilePath = path.join(dirname, 'folder.yml');
|
||||
await writeFile(ymlFilePath, ymlContent);
|
||||
writtenYmlFiles.push(ymlFilePath);
|
||||
bruFilesToDelete.push(bruFilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bruContent = fs.readFileSync(bruFilePath, 'utf8');
|
||||
const requestData = parseRequest(bruContent, { format: 'bru' });
|
||||
const ymlContent = stringifyRequest(requestData, { format: 'yml' });
|
||||
const ymlFilePath = bruFilePath.replace(/\.bru$/, '.yml');
|
||||
await writeFile(ymlFilePath, ymlContent);
|
||||
writtenYmlFiles.push(ymlFilePath);
|
||||
bruFilesToDelete.push(bruFilePath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(envDirPath)) {
|
||||
const envBruFiles = searchForFiles(envDirPath, '.bru');
|
||||
for (const envBruFilePath of envBruFiles) {
|
||||
const envBruContent = fs.readFileSync(envBruFilePath, 'utf8');
|
||||
const envData = parseEnvironment(envBruContent, { format: 'bru' });
|
||||
const ymlContent = stringifyEnvironment(envData, { format: 'yml' });
|
||||
const ymlFilePath = envBruFilePath.replace(/\.bru$/, '.yml');
|
||||
await writeFile(ymlFilePath, ymlContent);
|
||||
writtenYmlFiles.push(ymlFilePath);
|
||||
bruFilesToDelete.push(envBruFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bruFile of bruFilesToDelete) {
|
||||
fs.unlinkSync(bruFile);
|
||||
}
|
||||
fs.unlinkSync(brunoJsonPath);
|
||||
|
||||
const { size, filesCount } = await getCollectionStats(collectionPathname);
|
||||
ymlBrunoConfig.size = size;
|
||||
ymlBrunoConfig.filesCount = filesCount;
|
||||
|
||||
return ymlBrunoConfig;
|
||||
} catch (error) {
|
||||
for (const ymlFile of writtenYmlFiles) {
|
||||
try {
|
||||
if (fs.existsSync(ymlFile)) {
|
||||
fs.unlinkSync(ymlFile);
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the watcher on the original bru collection
|
||||
if (watcher) {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(path.join(collectionPathname, 'bruno.json'), 'utf8'));
|
||||
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, config);
|
||||
} catch (watcherError) {
|
||||
console.error('Failed to restart watcher after migration error:', watcherError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher) => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
meta {
|
||||
name: api
|
||||
}
|
||||
|
||||
headers {
|
||||
X-Folder-Header: api-folder
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: get-users
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/api/echo/json
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Accept: application/json
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// pre-request script in folder request
|
||||
bru.setVar("requestVar", "test");
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "migration-test",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
headers {
|
||||
X-Collection-Header: migration-test
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
// collection pre-request script
|
||||
bru.setVar("collectionVar", "hello");
|
||||
}
|
||||
|
||||
docs {
|
||||
# Migration Test Collection
|
||||
This collection is used to test bru to yml migration.
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
vars {
|
||||
host: http://localhost:8081
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
vars {
|
||||
host: https://api.example.com
|
||||
}
|
||||
15
tests/collection/migrate-to-yml/fixtures/collection/ping.bru
Normal file
15
tests/collection/migrate-to-yml/fixtures/collection/ping.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: ping
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
meta {
|
||||
name: post-json
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/echo/json
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
headers {
|
||||
Content-Type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"message": "hello from migration test"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
res.status: eq 200
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": ["{{collectionPath}}"],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
77
tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts
Normal file
77
tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, openCollection } from '../../utils/page';
|
||||
|
||||
const DISMISSED_LOCAL_STORAGE_KEY = 'bruno.migrateToYmlPill.dismissed';
|
||||
|
||||
test.describe('Migrate-to-YML pill in collection toolbar', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should show the pill for bru collections, open settings on click, and persist dismissal', async ({
|
||||
pageWithUserData: page,
|
||||
collectionFixturePath
|
||||
}) => {
|
||||
const collectionPath = collectionFixturePath!;
|
||||
|
||||
const pageErrors: Error[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error));
|
||||
|
||||
await test.step('Clear any prior dismissal state for this collection', async () => {
|
||||
await page.evaluate((key) => {
|
||||
localStorage.removeItem(key);
|
||||
}, DISMISSED_LOCAL_STORAGE_KEY);
|
||||
});
|
||||
|
||||
await test.step('Open the bru collection', async () => {
|
||||
await openCollection(page, 'migration-test');
|
||||
});
|
||||
|
||||
const pill = page.getByTestId('migrate-yml-pill');
|
||||
const pillDismiss = page.getByTestId('migrate-yml-pill-dismiss');
|
||||
|
||||
await test.step('Pill is visible in the collection toolbar with the expected label and dismiss icon', async () => {
|
||||
await expect(pill).toBeVisible({ timeout: 10000 });
|
||||
await expect(pill).toContainText('Migrate to YML');
|
||||
await expect(pillDismiss).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Clicking the pill body opens the collection settings overview without dismissing', async () => {
|
||||
await pill.click();
|
||||
await expect(page.getByTestId('collection-settings-tab-overview')).toBeVisible();
|
||||
await expect(page.getByText('Migrate to YML file format')).toBeVisible();
|
||||
// Pill should still be visible — clicking the body navigates, it should not dismiss
|
||||
await expect(pill).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Dismiss the pill via the cross icon', async () => {
|
||||
await pillDismiss.click();
|
||||
await expect(pill).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step('Dismissal is persisted to localStorage keyed by collection pathname', async () => {
|
||||
const stored = await page.evaluate(
|
||||
(key) => localStorage.getItem(key),
|
||||
DISMISSED_LOCAL_STORAGE_KEY
|
||||
);
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
|
||||
// collection.pathname is stored as-is; normalise separators for the cross-platform check
|
||||
const normalisedStored = parsed.map((p: string) => p.replace(/\\/g, '/'));
|
||||
const normalisedCollectionPath = collectionPath.replace(/\\/g, '/');
|
||||
expect(normalisedStored).toContain(normalisedCollectionPath);
|
||||
});
|
||||
|
||||
await test.step('Pill stays hidden when switching between collection settings tabs', async () => {
|
||||
await page.getByTestId('collection-settings-tab-headers').click();
|
||||
await expect(pill).toBeHidden();
|
||||
await page.getByTestId('collection-settings-tab-overview').click();
|
||||
await expect(pill).toBeHidden();
|
||||
});
|
||||
|
||||
expect(pageErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
135
tests/collection/migrate-to-yml/migrate-to-yml.spec.ts
Normal file
135
tests/collection/migrate-to-yml/migrate-to-yml.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, openCollection, selectEnvironment, sendRequestAndWaitForResponse } from '../../utils/page';
|
||||
|
||||
test.describe('Migrate collection from bru to yml format', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should migrate bru collection to yml and preserve all data', async ({ pageWithUserData: page, collectionFixturePath }) => {
|
||||
const collectionPath = collectionFixturePath!;
|
||||
|
||||
// Capture any uncaught errors during migration
|
||||
const pageErrors: Error[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error));
|
||||
|
||||
await test.step('Verify collection is in bru format before migration', async () => {
|
||||
expect(fs.existsSync(path.join(collectionPath, 'bruno.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'collection.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'ping.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'post-json.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'folder.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'get-users.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Local.bru'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Production.bru'))).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Open collection and navigate to overview', async () => {
|
||||
await openCollection(page, 'migration-test');
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'migration-test' }).click();
|
||||
await page.getByTestId('collection-settings-tab-overview').click();
|
||||
});
|
||||
|
||||
await test.step('Verify migration section is visible for bru collection', async () => {
|
||||
await expect(page.getByText('Migrate to YML file format')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Convert to YML' })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Click Convert to YML and confirm migration', async () => {
|
||||
await page.getByRole('button', { name: 'Convert to YML' }).click();
|
||||
|
||||
// Confirmation modal should appear
|
||||
const modal = page.locator('.bruno-modal').filter({ hasText: 'Migrate to YML format' });
|
||||
await modal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Verify modal content mentions the collection name
|
||||
await expect(modal.getByText('migration-test')).toBeVisible();
|
||||
|
||||
// Confirm migration
|
||||
await modal.getByRole('button', { name: 'Migrate' }).click();
|
||||
});
|
||||
|
||||
await test.step('Wait for migration to complete and collection to reload', async () => {
|
||||
// Wait for success toast
|
||||
await expect(page.getByText('Collection migrated to YML format successfully')).toBeVisible({ timeout: 30000 });
|
||||
});
|
||||
|
||||
await test.step('Verify all bru files are removed from disk', async () => {
|
||||
expect(fs.existsSync(path.join(collectionPath, 'bruno.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'collection.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'ping.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'post-json.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'folder.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'get-users.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Local.bru'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Production.bru'))).toBe(false);
|
||||
});
|
||||
|
||||
await test.step('Verify all yml files are created on disk', async () => {
|
||||
expect(fs.existsSync(path.join(collectionPath, 'opencollection.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'ping.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'post-json.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'folder.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'api', 'get-users.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Local.yml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(collectionPath, 'environments', 'Production.yml'))).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Verify opencollection.yml has correct config', async () => {
|
||||
const ocContent = fs.readFileSync(path.join(collectionPath, 'opencollection.yml'), 'utf8');
|
||||
expect(ocContent).toContain('opencollection');
|
||||
expect(ocContent).toContain('migration-test');
|
||||
});
|
||||
|
||||
await test.step('Verify request yml files preserve data', async () => {
|
||||
const pingContent = fs.readFileSync(path.join(collectionPath, 'ping.yml'), 'utf8');
|
||||
expect(pingContent).toContain('ping');
|
||||
expect(pingContent).toContain('/ping');
|
||||
expect(pingContent).toContain('GET');
|
||||
|
||||
const postContent = fs.readFileSync(path.join(collectionPath, 'post-json.yml'), 'utf8');
|
||||
expect(postContent).toContain('post-json');
|
||||
expect(postContent).toContain('POST');
|
||||
expect(postContent).toContain('hello from migration test');
|
||||
});
|
||||
|
||||
await test.step('Verify folder yml file preserves data', async () => {
|
||||
const folderContent = fs.readFileSync(path.join(collectionPath, 'api', 'folder.yml'), 'utf8');
|
||||
expect(folderContent).toContain('api');
|
||||
expect(folderContent).toContain('X-Folder-Header');
|
||||
});
|
||||
|
||||
await test.step('Verify environment yml files preserve data', async () => {
|
||||
const localEnvContent = fs.readFileSync(path.join(collectionPath, 'environments', 'Local.yml'), 'utf8');
|
||||
expect(localEnvContent).toContain('host');
|
||||
expect(localEnvContent).toContain('http://localhost:8081');
|
||||
|
||||
const prodEnvContent = fs.readFileSync(path.join(collectionPath, 'environments', 'Production.yml'), 'utf8');
|
||||
expect(prodEnvContent).toContain('host');
|
||||
expect(prodEnvContent).toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
await test.step('Verify collection items are loaded in sidebar', async () => {
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'migration-test' })).toBeVisible();
|
||||
await expect(page.locator('.item-name').filter({ hasText: 'ping' })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('.item-name').filter({ hasText: 'post-json' })).toBeVisible();
|
||||
await expect(page.locator('.item-name').filter({ hasText: 'api' })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify migration section is hidden after migration', async () => {
|
||||
await page.getByTestId('collection-settings-tab-overview').click();
|
||||
await expect(page.getByText('Migrate to YML file format')).not.toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify migrated requests are functional', async () => {
|
||||
await selectEnvironment(page, 'Local');
|
||||
await page.locator('.item-name').filter({ hasText: 'ping' }).click();
|
||||
await sendRequestAndWaitForResponse(page, 200);
|
||||
});
|
||||
|
||||
// Verify no uncaught JS errors occurred during migration
|
||||
expect(pageErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user