mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 14:08:38 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
115
packages/bruno-electron/tests/services/snapshot-remap.spec.js
Normal file
115
packages/bruno-electron/tests/services/snapshot-remap.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user