fix(migration): remaped snapshot tab paths and preserve uids on bru-t… (#8450)

* fix(migration): remaped snapshot tab paths and preserve uids on bru-to-yml migration

* ci: re-trigger checks

---------

Co-authored-by: Utkarsh <utkarsh@usebruno.com>
This commit is contained in:
ravindra-bruno
2026-07-06 18:47:26 +05:30
committed by GitHub
parent 153f0cd91f
commit e9158123bd
4 changed files with 277 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string[]> => {
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);
});
});