feat: implement collection migration to YML format (#7669)

This commit is contained in:
naman-bruno
2026-06-22 15:01:43 +05:30
committed by GitHub
parent 21efded2bf
commit d1ebf578b2
18 changed files with 834 additions and 3 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 &#x2197;
</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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
});
});
};

View File

@@ -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) => {

View File

@@ -0,0 +1,7 @@
meta {
name: api
}
headers {
X-Folder-Header: api-folder
}

View File

@@ -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
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "migration-test",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -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.
}

View File

@@ -0,0 +1,3 @@
vars {
host: http://localhost:8081
}

View File

@@ -0,0 +1,3 @@
vars {
host: https://api.example.com
}

View 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,10 @@
{
"maximized": false,
"lastOpenedCollections": ["{{collectionPath}}"],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View 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);
});
});

View 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);
});
});