From d1ebf578b2a18ccae9e538f2b8c6df88c03d1ecd Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Mon, 22 Jun 2026 15:01:43 +0530 Subject: [PATCH] feat: implement collection migration to YML format (#7669) --- .../CollectionSettings/Overview/Info/index.js | 3 + .../Overview/Migration/StyledWrapper.js | 55 +++++++ .../Overview/Migration/index.js | 135 ++++++++++++++++++ .../CollectionHeader/StyledWrapper.js | 56 ++++++++ .../RequestTabs/CollectionHeader/index.js | 75 +++++++++- .../ReduxStore/slices/collections/actions.js | 73 +++++++++- packages/bruno-electron/src/ipc/collection.js | 119 +++++++++++++++ .../fixtures/collection/api/folder.bru | 7 + .../fixtures/collection/api/get-users.bru | 24 ++++ .../fixtures/collection/bruno.json | 9 ++ .../fixtures/collection/collection.bru | 13 ++ .../collection/environments/Local.bru | 3 + .../collection/environments/Production.bru | 3 + .../fixtures/collection/ping.bru | 15 ++ .../fixtures/collection/post-json.bru | 25 ++++ .../init-user-data/preferences.json | 10 ++ .../migrate-to-yml-pill.spec.ts | 77 ++++++++++ .../migrate-to-yml/migrate-to-yml.spec.ts | 135 ++++++++++++++++++ 18 files changed, 834 insertions(+), 3 deletions(-) create mode 100644 packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/api/folder.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/api/get-users.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/bruno.json create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/collection.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/environments/Local.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/environments/Production.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/ping.bru create mode 100644 tests/collection/migrate-to-yml/fixtures/collection/post-json.bru create mode 100644 tests/collection/migrate-to-yml/init-user-data/preferences.json create mode 100644 tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts create mode 100644 tests/collection/migrate-to-yml/migrate-to-yml.spec.ts diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js index 888149f78..86e38d3fc 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -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 }) => { {showGenerateDocumentationModal && setShowGenerateDocumentationModal(false)} />} + + diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js new file mode 100644 index 000000000..e6151a309 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js new file mode 100644 index 000000000..0665edfb6 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Migration/index.js @@ -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 ( + +
+
+ + Migration +
+ +
+
+ +
+
+
Migrate to YML file format
+
+ This collection is stored in BRU format.{' '} + Switch to YML.{' '} + + Learn More ↗ + +
+ +
+
+
+ + {showConfirmModal && ( + setShowConfirmModal(false)} + > +
+

+ This will convert all files in {collection.name} from .bru format to .yml format. +

+
+

What will happen:

+
    +
  • All .bru request files will be converted to .yml
  • +
  • Environment files will be converted to YML format
  • +
  • bruno.json will be replaced with opencollection.yml
  • +
  • The collection will be reloaded after migration
  • +
+
+
+
+ Backup +
+

+ Export this collection as a ZIP archive before migrating, in case you want to restore it later. +

+
+ +
+
+
+
+ )} +
+ ); +}; + +export default Migration; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js index 5ae18d474..599c773ac 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 2e9bf7f07..0744ace4e 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -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 && (
+ {collection.format === 'bru' && !migratePillDismissed && ( +
+ + +
+ )} {/* OpenAPI Sync - standalone only when configured and beta enabled */} {hasOpenApiSyncConfigured && ( 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); + }); + }); +}; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 547ac6404..2e3b56f7b 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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) => { diff --git a/tests/collection/migrate-to-yml/fixtures/collection/api/folder.bru b/tests/collection/migrate-to-yml/fixtures/collection/api/folder.bru new file mode 100644 index 000000000..a12392eac --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/api/folder.bru @@ -0,0 +1,7 @@ +meta { + name: api +} + +headers { + X-Folder-Header: api-folder +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/api/get-users.bru b/tests/collection/migrate-to-yml/fixtures/collection/api/get-users.bru new file mode 100644 index 000000000..be7648ce7 --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/api/get-users.bru @@ -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 +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/bruno.json b/tests/collection/migrate-to-yml/fixtures/collection/bruno.json new file mode 100644 index 000000000..ae83c17ca --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "migration-test", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/collection.bru b/tests/collection/migrate-to-yml/fixtures/collection/collection.bru new file mode 100644 index 000000000..354bf7920 --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/collection.bru @@ -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. +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/environments/Local.bru b/tests/collection/migrate-to-yml/fixtures/collection/environments/Local.bru new file mode 100644 index 000000000..130c2b5dd --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + host: http://localhost:8081 +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/environments/Production.bru b/tests/collection/migrate-to-yml/fixtures/collection/environments/Production.bru new file mode 100644 index 000000000..0dce48225 --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/environments/Production.bru @@ -0,0 +1,3 @@ +vars { + host: https://api.example.com +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/ping.bru b/tests/collection/migrate-to-yml/fixtures/collection/ping.bru new file mode 100644 index 000000000..f8227812a --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/ping.bru @@ -0,0 +1,15 @@ +meta { + name: ping + type: http + seq: 1 +} + +get { + url: {{host}}/ping + body: none + auth: none +} + +assert { + res.status: eq 200 +} diff --git a/tests/collection/migrate-to-yml/fixtures/collection/post-json.bru b/tests/collection/migrate-to-yml/fixtures/collection/post-json.bru new file mode 100644 index 000000000..5f877a7d6 --- /dev/null +++ b/tests/collection/migrate-to-yml/fixtures/collection/post-json.bru @@ -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 +} diff --git a/tests/collection/migrate-to-yml/init-user-data/preferences.json b/tests/collection/migrate-to-yml/init-user-data/preferences.json new file mode 100644 index 000000000..e8528c5e9 --- /dev/null +++ b/tests/collection/migrate-to-yml/init-user-data/preferences.json @@ -0,0 +1,10 @@ +{ + "maximized": false, + "lastOpenedCollections": ["{{collectionPath}}"], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts b/tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts new file mode 100644 index 000000000..3f7ff6b4c --- /dev/null +++ b/tests/collection/migrate-to-yml/migrate-to-yml-pill.spec.ts @@ -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); + }); +}); diff --git a/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts b/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts new file mode 100644 index 000000000..6abec54e0 --- /dev/null +++ b/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts @@ -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); + }); +});