diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 9da1efb83..6f27f1712 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -2684,6 +2684,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { // Track all written yml files so we can roll back on failure const writtenYmlFiles = []; + const tabPathMap = {}; + try { const brunoJsonPath = path.join(collectionPathname, 'bruno.json'); const brunoJsonContent = fs.readFileSync(brunoJsonPath, 'utf8'); @@ -2743,6 +2745,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { const ymlContent = stringifyRequest(requestData, { format: 'yml' }); const ymlFilePath = bruFilePath.replace(/\.bru$/, '.yml'); await writeFile(ymlFilePath, ymlContent); + moveRequestUid(bruFilePath, ymlFilePath); + tabPathMap[bruFilePath] = ymlFilePath; writtenYmlFiles.push(ymlFilePath); bruFilesToDelete.push(bruFilePath); } @@ -2755,6 +2759,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { const ymlContent = stringifyEnvironment(envData, { format: 'yml' }); const ymlFilePath = envBruFilePath.replace(/\.bru$/, '.yml'); await writeFile(ymlFilePath, ymlContent); + moveRequestUid(envBruFilePath, ymlFilePath); writtenYmlFiles.push(ymlFilePath); bruFilesToDelete.push(envBruFilePath); } @@ -2765,6 +2770,11 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } fs.unlinkSync(brunoJsonPath); + try { + snapshotManager.remapCollectionTabPaths(collectionPathname, tabPathMap); + } catch (_) { + } + const { size, filesCount } = await getCollectionStats(collectionPathname); ymlBrunoConfig.size = size; ymlBrunoConfig.filesCount = filesCount; diff --git a/packages/bruno-electron/src/services/snapshot/index.js b/packages/bruno-electron/src/services/snapshot/index.js index ee1c88361..e9f16cf23 100644 --- a/packages/bruno-electron/src/services/snapshot/index.js +++ b/packages/bruno-electron/src/services/snapshot/index.js @@ -206,6 +206,84 @@ class SnapshotManager { })); } + remapCollectionTabPaths(collectionPathname, pathMap) { + const normalizedCollectionPath = normalizeLookupKey(collectionPathname); + if (!normalizedCollectionPath || !isObject(pathMap)) { + return; + } + + const normalizedPathMap = {}; + Object.entries(pathMap).forEach(([oldPath, newPath]) => { + const normalizedOld = normalizeLookupKey(oldPath); + if (normalizedOld && typeof newPath === 'string' && newPath) { + normalizedPathMap[normalizedOld] = newPath; + } + }); + + if (Object.keys(normalizedPathMap).length === 0) { + return; + } + + const remapPathname = (pathname) => { + if (typeof pathname !== 'string') { + return pathname; + } + const normalized = normalizeLookupKey(pathname); + return normalizedPathMap[normalized] || pathname; + }; + + const remapActiveTab = (activeTab) => { + if (!isObject(activeTab) || typeof activeTab.value !== 'string') { + return activeTab; + } + if (activeTab.accessor === 'pathname') { + return { accessor: activeTab.accessor, value: remapPathname(activeTab.value) }; + } + if (activeTab.accessor === 'pathname::exampleName' || activeTab.accessor === 'pathname::exampleIndex') { + const separatorIndex = activeTab.value.indexOf('::'); + if (separatorIndex === -1) { + return activeTab; + } + const pathnamePart = activeTab.value.slice(0, separatorIndex); + const suffix = activeTab.value.slice(separatorIndex); + return { accessor: activeTab.accessor, value: `${remapPathname(pathnamePart)}${suffix}` }; + } + return activeTab; + }; + + const snapshot = this._normalizeSnapshot(this.store.store); + let changed = false; + + snapshot.collections.forEach((collection) => { + if (normalizeLookupKey(collection.pathname) !== normalizedCollectionPath) { + return; + } + + if (Array.isArray(collection.tabs)) { + collection.tabs.forEach((tab) => { + if (tab && typeof tab.pathname === 'string') { + const remapped = remapPathname(tab.pathname); + if (remapped !== tab.pathname) { + tab.pathname = remapped; + changed = true; + } + } + }); + } + + const remappedActiveTab = remapActiveTab(collection.activeTab); + if (remappedActiveTab !== collection.activeTab) { + collection.activeTab = remappedActiveTab; + changed = true; + } + }); + + if (changed) { + this.store.store = snapshot; + this._lookupCache = null; + } + } + setCollection(pathname, data) { const normalizedPath = normalizeLookupKey(pathname); if (!normalizedPath) { diff --git a/packages/bruno-electron/tests/services/snapshot-remap.spec.js b/packages/bruno-electron/tests/services/snapshot-remap.spec.js new file mode 100644 index 000000000..81395390e --- /dev/null +++ b/packages/bruno-electron/tests/services/snapshot-remap.spec.js @@ -0,0 +1,115 @@ +let mockStoreData = {}; + +jest.mock('electron-store', () => { + return jest.fn().mockImplementation((opts = {}) => { + mockStoreData = { ...(opts.defaults || {}) }; + return { + get store() { + return mockStoreData; + }, + set store(value) { + mockStoreData = value; + }, + get: (key, fallback) => (key in mockStoreData ? mockStoreData[key] : fallback), + set: (key, value) => { + mockStoreData[key] = value; + }, + delete: (key) => { + delete mockStoreData[key]; + } + }; + }); +}); + +const snapshotManager = require('../../src/services/snapshot'); + +const collectionPath = '/collections/my-collection'; + +const seedSnapshot = () => { + snapshotManager.saveSnapshot({ + version: '0.0.1', + activeWorkspacePath: null, + extras: { devTools: { open: false, activeTab: '', tabs: {} } }, + workspaces: [], + collections: [ + { + pathname: collectionPath, + workspacePathname: '', + environment: { collection: '', global: '' }, + isOpen: true, + isMounted: true, + activeTab: { accessor: 'pathname', value: `${collectionPath}/ping.bru` }, + tabs: [ + { type: 'http-request', accessor: 'pathname', pathname: `${collectionPath}/ping.bru`, permanent: true }, + { type: 'http-request', accessor: 'pathname', pathname: `${collectionPath}/api/get-users.bru`, permanent: true }, + { + type: 'response-example', + accessor: 'pathname::exampleName', + pathname: `${collectionPath}/ping.bru`, + exampleName: 'success', + permanent: true + }, + { type: 'collection-settings', accessor: 'type', permanent: true } + ] + } + ] + }); +}; + +describe('SnapshotManager.remapCollectionTabPaths', () => { + beforeEach(() => { + mockStoreData = {}; + seedSnapshot(); + }); + + it('remaps .bru request tab pathnames to their .yml equivalents', () => { + snapshotManager.remapCollectionTabPaths(collectionPath, { + [`${collectionPath}/ping.bru`]: `${collectionPath}/ping.yml`, + [`${collectionPath}/api/get-users.bru`]: `${collectionPath}/api/get-users.yml` + }); + + const { tabs } = snapshotManager.getTabs(collectionPath); + const pathnames = tabs.filter((t) => t.pathname).map((t) => t.pathname); + + expect(pathnames).toContain(`${collectionPath}/ping.yml`); + expect(pathnames).toContain(`${collectionPath}/api/get-users.yml`); + expect(pathnames.some((p) => p.endsWith('.bru'))).toBe(false); + }); + + it('remaps the active tab value', () => { + snapshotManager.remapCollectionTabPaths(collectionPath, { + [`${collectionPath}/ping.bru`]: `${collectionPath}/ping.yml` + }); + + const { activeTab } = snapshotManager.getTabs(collectionPath); + expect(activeTab).toEqual({ accessor: 'pathname', value: `${collectionPath}/ping.yml` }); + }); + + it('remaps the pathname prefix of example tabs while preserving the example suffix', () => { + snapshotManager.remapCollectionTabPaths(collectionPath, { + [`${collectionPath}/ping.bru`]: `${collectionPath}/ping.yml` + }); + + const { tabs } = snapshotManager.getTabs(collectionPath); + const exampleTab = tabs.find((t) => t.type === 'response-example'); + expect(exampleTab.pathname).toBe(`${collectionPath}/ping.yml`); + expect(exampleTab.exampleName).toBe('success'); + }); + + it('leaves non-request tabs and unmapped pathnames untouched', () => { + snapshotManager.remapCollectionTabPaths(collectionPath, { + [`${collectionPath}/ping.bru`]: `${collectionPath}/ping.yml` + }); + + const { tabs } = snapshotManager.getTabs(collectionPath); + expect(tabs.find((t) => t.type === 'collection-settings')).toBeTruthy(); + expect(tabs.some((t) => t.pathname === `${collectionPath}/api/get-users.bru`)).toBe(true); + }); + + it('is a no-op when the path map is empty', () => { + snapshotManager.remapCollectionTabPaths(collectionPath, {}); + + const { tabs } = snapshotManager.getTabs(collectionPath); + expect(tabs.some((t) => t.pathname === `${collectionPath}/ping.bru`)).toBe(true); + }); +}); diff --git a/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts b/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts index 4c4a54715..12828eb34 100644 --- a/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts +++ b/tests/collection/migrate-to-yml/migrate-to-yml.spec.ts @@ -136,4 +136,78 @@ test.describe('Migrate collection from bru to yml format', () => { // Verify no uncaught JS errors occurred during migration expect(pageErrors).toHaveLength(0); }); + + test('should keep a request tab open during migration functional (no "Request no longer exists")', async ({ pageWithUserData: page, collectionFixturePath }) => { + const collectionPath = collectionFixturePath!; + + const pageErrors: Error[] = []; + page.on('pageerror', (error) => pageErrors.push(error)); + + const readSnapshotTabPathnames = async (): Promise => { + const tabsSnapshot = await page.evaluate( + (path) => (window as any).ipcRenderer.invoke('renderer:snapshot:get-tabs', path, null), + collectionPath + ); + return ((tabsSnapshot?.tabs || []) as Array<{ pathname?: string }>) + .map((tab) => tab.pathname) + .filter((pathname): pathname is string => typeof pathname === 'string'); + }; + + await test.step('Open the ping request as a permanent tab', async () => { + await openCollection(page, 'migration-test'); + await page.locator('.item-name').filter({ hasText: 'ping' }).dblclick(); + + const urlContainer = page.locator('#request-url'); + await expect(urlContainer).toBeVisible(); + await expect(urlContainer.locator('.CodeMirror')).toContainText('/ping'); + }); + + await test.step('The open tab is persisted to the UI-state snapshot as a .bru tab', async () => { + await expect + .poll(async () => (await readSnapshotTabPathnames()).some((p) => p.endsWith('ping.bru')), { timeout: 10000 }) + .toBe(true); + }); + + await test.step('Migrate the collection to yml', async () => { + await page.locator('#sidebar-collection-name').filter({ hasText: 'migration-test' }).click(); + await page.getByTestId('collection-settings-tab-overview').click(); + await page.getByRole('button', { name: 'Convert to YML' }).click(); + + const modal = page.locator('.bruno-modal').filter({ hasText: 'Migrate to YML format' }); + await modal.waitFor({ state: 'visible', timeout: 5000 }); + await modal.getByRole('button', { name: 'Migrate' }).click(); + + await expect(page.getByText('Collection migrated to YML format successfully')).toBeVisible({ timeout: 30000 }); + }); + + await test.step('The migrated files exist on disk', async () => { + expect(fs.existsSync(path.join(collectionPath, 'ping.yml'))).toBe(true); + expect(fs.existsSync(path.join(collectionPath, 'ping.bru'))).toBe(false); + }); + + await test.step('The persisted snapshot no longer references any .bru request tab', async () => { + await expect + .poll(async () => (await readSnapshotTabPathnames()).some((p) => p.endsWith('.bru')), { timeout: 10000 }) + .toBe(false); + }); + + await test.step('Migration does not leave a broken "Request no longer exists" tab', async () => { + await expect(page.getByText('Request no longer exists')).not.toBeVisible(); + }); + + await test.step('The migrated request opens against the yml file and is runnable', async () => { + await page.locator('.item-name').filter({ hasText: 'ping' }).click(); + + await expect(page.getByText('Request no longer exists')).not.toBeVisible(); + + const urlContainer = page.locator('#request-url'); + await expect(urlContainer).toBeVisible(); + await expect(urlContainer.locator('.CodeMirror')).toContainText('/ping'); + + await selectEnvironment(page, 'Local'); + await sendRequestAndWaitForResponse(page, 200); + }); + + expect(pageErrors).toHaveLength(0); + }); });