From 71f5659763d9d19bdc3f71dde6e23d6e925a2a27 Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 20 May 2026 22:25:06 +0530 Subject: [PATCH] feat: error-boundary + crash cache addition (#8056) * internal emit chain for clearance * fix: crash ui * fix(ErrorBoundary): ensure cache clearing is awaited before force quitting * test(e2e): environment persistence across collections * test: migration test * Update environment.spec.ts * fix: reduce padding for dark mode app errors * chore: re-add waitForReadyPage --- .../src/pages/ErrorBoundary/index.js | 34 ++- .../middlewares/snapshot/middleware.js | 17 +- packages/bruno-electron/src/index.js | 5 + packages/bruno-electron/src/ipc/snapshot.js | 8 + .../src/services/snapshot/index.js | 17 ++ playwright/index.ts | 22 ++ .../snapshots/environment/environment.spec.ts | 220 ++++++++++++++++++ .../fixtures/collection/bruno.json | 10 + .../collection/environments/local.bru | 4 + .../fixtures/collection/request.bru | 9 + .../init-user-data/preferences.json | 12 + .../init-user-data/ui-state-snapshot.json | 8 + tests/utils/page/actions.ts | 12 +- 13 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 tests/snapshots/environment/environment.spec.ts create mode 100644 tests/snapshots/environment/fixtures/collection/bruno.json create mode 100644 tests/snapshots/environment/fixtures/collection/environments/local.bru create mode 100644 tests/snapshots/environment/fixtures/collection/request.bru create mode 100644 tests/snapshots/environment/init-user-data/preferences.json create mode 100644 tests/snapshots/environment/init-user-data/ui-state-snapshot.json diff --git a/packages/bruno-app/src/pages/ErrorBoundary/index.js b/packages/bruno-app/src/pages/ErrorBoundary/index.js index 944cb5d9d..3da54913b 100644 --- a/packages/bruno-app/src/pages/ErrorBoundary/index.js +++ b/packages/bruno-app/src/pages/ErrorBoundary/index.js @@ -6,7 +6,7 @@ class ErrorBoundary extends React.Component { constructor(props) { super(props); - this.state = { hasError: false }; + this.state = { hasError: false, clearCaches: false }; } componentDidMount() { @@ -21,6 +21,10 @@ class ErrorBoundary extends React.Component { this.setState({ hasError: true, error, errorInfo }); } + async clearCache() { + await window.ipcRenderer.invoke('main:cache-clear'); + } + returnToApp() { const { ipcRenderer } = window; ipcRenderer.invoke('open-file'); @@ -36,7 +40,7 @@ class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { return ( -
+
@@ -63,8 +67,30 @@ class ErrorBoundary extends React.Component { Return to App -
- + diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js index e4c21b540..cd0766e68 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js @@ -157,6 +157,9 @@ const serializeSnapshot = async (state) => { const workspacePathname = activeWorkspace?.pathname || ''; const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname); + const existingCollection = (collectionSnapshotKey && existingSnapshotLookups.collectionsByWorkspaceAndPath?.[collectionSnapshotKey]) + || existingSnapshotLookups.collectionsByPath?.[normalizedPath] + || null; if (collectionSnapshotKey) { serializedCollectionKeys.add(collectionSnapshotKey); } @@ -174,7 +177,17 @@ const serializeSnapshot = async (state) => { ); const selectedEnvironment = (collection.environments || []).find((env) => env.uid === collection.activeEnvironmentUid); - const environmentPath = getCollectionEnvironmentPath(collection, selectedEnvironment, ''); + const environmentPathFromRedux = getCollectionEnvironmentPath(collection, selectedEnvironment, ''); + const selectedEnvironmentFromRedux = selectedEnvironment?.name || ''; + const existingEnvironmentPath = existingCollection?.environment?.collection || existingCollection?.environmentPath || ''; + const existingSelectedEnvironment = existingCollection?.selectedEnvironment || ''; + const shouldPreserveExistingEnvironment = collection.mountStatus !== 'mounted' + && !environmentPathFromRedux + && !selectedEnvironmentFromRedux; + const environmentPath = shouldPreserveExistingEnvironment ? existingEnvironmentPath : environmentPathFromRedux; + const selectedEnvironmentName = shouldPreserveExistingEnvironment + ? existingSelectedEnvironment + : selectedEnvironmentFromRedux; snapshot.collections.push({ pathname: collection.pathname, @@ -184,7 +197,7 @@ const serializeSnapshot = async (state) => { global: globalEnvironments.activeGlobalEnvironmentUid || '' }, environmentPath, - selectedEnvironment: selectedEnvironment?.name || '', + selectedEnvironment: selectedEnvironmentName, isOpen: !collection.collapsed, isMounted: collection.mountStatus === 'mounted', activeTab: serializeActiveTab(activeTabInCollection, collection), diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 7ebb0ee0f..2a8d13545 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -471,6 +471,11 @@ app.on('ready', async () => { registerSystemMonitorIpc(mainWindow, systemMonitor); registerGitIpc(mainWindow); registerOpenAPISyncIpc(mainWindow); + + // Internal delegator + ipcMain.handle('main:cache-clear', async () => { + ipcMain.emit('internal:snapshot:reset'); + }); }); // Quit the app once all windows are closed diff --git a/packages/bruno-electron/src/ipc/snapshot.js b/packages/bruno-electron/src/ipc/snapshot.js index 0272ed904..d360e6fe7 100644 --- a/packages/bruno-electron/src/ipc/snapshot.js +++ b/packages/bruno-electron/src/ipc/snapshot.js @@ -10,6 +10,14 @@ const registerSnapshotIpc = () => { return snapshotManager.getTabs(collectionPathname, workspacePathname); }); + ipcMain.on('internal:snapshot:reset', () => { + try { + snapshotManager.resetSnapshot(); + } catch (err) { + // digest error if reset fails + } + }); + ipcMain.handle('renderer:snapshot:save', async (event, data) => { return snapshotManager.saveSnapshot(data); }); diff --git a/packages/bruno-electron/src/services/snapshot/index.js b/packages/bruno-electron/src/services/snapshot/index.js index 9e0e51c0d..ea2df8b0b 100644 --- a/packages/bruno-electron/src/services/snapshot/index.js +++ b/packages/bruno-electron/src/services/snapshot/index.js @@ -187,6 +187,23 @@ class SnapshotManager { } } + resetSnapshot() { + this.store.delete('activeWorkspacePath'); + this.store.set('workspaces', (this.store.store?.workspaces ?? []).map((d) => { + d.lastActiveCollectionPathname = undefined; + return d; + })); + this.store.set('collections', (this.store.store?.collections ?? []).map((d) => { + if ('tabs' in d) { + d.tabs = []; + } + if ('activeTab' in d) { + d.activeTab = undefined; + } + return d; + })); + } + setCollection(pathname, data) { const normalizedPath = normalizeLookupKey(pathname); if (!normalizedPath) { diff --git a/playwright/index.ts b/playwright/index.ts index 91c8d949e..1fc98aa1f 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -31,6 +31,28 @@ function isTracingEnabled(testInfo: TestInfo): boolean { return !!(testInfo as any)._tracing.traceOptions(); } +// Wait for the Electron app to have a ready, loaded window. +// Handles cases where the first window is slow to appear (e.g. on Windows). +export async function waitForReadyPage(app: ElectronApplication, options: { timeout?: number } = {}): Promise { + const { timeout = 45000 } = options; + + let page: Page | null = null; + try { + page = await app.firstWindow(); + } catch { + page = null; + } + + if (!page) { + page = await app.waitForEvent('window', { timeout }); + } + + await page.locator('[data-app-state="loaded"]').waitFor({ timeout }); + await page.waitForTimeout(200); + + return page; +} + async function usePageWithTracing( context: BrowserContext, page: Page, diff --git a/tests/snapshots/environment/environment.spec.ts b/tests/snapshots/environment/environment.spec.ts new file mode 100644 index 000000000..4b813b205 --- /dev/null +++ b/tests/snapshots/environment/environment.spec.ts @@ -0,0 +1,220 @@ +import path from 'path'; +import fs from 'fs'; +import { test, expect, closeElectronApp } from '../../../playwright'; +import { + createCollection, + createEnvironment, + openCollection, + selectEnvironment, + waitForReadyPage +} from '../../utils/page'; + +const readSnapshot = (userDataPath: string) => { + const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json'); + if (!fs.existsSync(snapshotPath)) { + return null; + } + + return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')); +}; + +const legacyPromptVariablesInitUserDataPath = path.join( + __dirname, + 'init-user-data' +); + +const migrationCollectionPath = path.join( + __dirname, + 'fixtures/collection' +); + +test.describe('Snapshot: Collection Environment Persistence', () => { + test('migrates legacy snapshot format and preserves selected collection environment', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-legacy-env-migration'); + + const app = await launchElectronApp({ + initUserDataPath: legacyPromptVariablesInitUserDataPath, + userDataPath + }); + const page = await waitForReadyPage(app); + + await test.step('Verify legacy selected environment is hydrated in UI', async () => { + await openCollection(page, 'migration-collection'); + await expect(page.locator('.current-environment')).toContainText('local'); + }); + + await test.step('Close app and verify snapshot migrated to new shape', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + + const snapshot = readSnapshot(userDataPath); + expect(snapshot).not.toBeNull(); + expect(snapshot).toHaveProperty('version'); + expect(snapshot).toHaveProperty('activeWorkspacePath'); + expect(snapshot).toHaveProperty('extras'); + expect(snapshot).toHaveProperty('workspaces'); + expect(snapshot).toHaveProperty('collections'); + expect(Array.isArray(snapshot?.workspaces)).toBe(true); + expect(Array.isArray(snapshot?.collections)).toBe(true); + + const migratedCollectionEntry = snapshot?.collections?.find( + (collection: any) => collection?.pathname === migrationCollectionPath + ); + expect(migratedCollectionEntry).toBeTruthy(); + console.log(JSON.stringify(migratedCollectionEntry)); + + expect(migratedCollectionEntry?.selectedEnvironment).toBe('local'); + }); + }); + + test('keeps selected environments for non-active collections across snapshot saves', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-env-persistence'); + const firstCollectionPath = await createTmpDir('snap-col-a'); + const secondCollectionPath = await createTmpDir('snap-col-b'); + const firstCollectionRoot = path.join(firstCollectionPath, 'Collection A'); + const secondCollectionRoot = path.join(secondCollectionPath, 'Collection B'); + + const app = await launchElectronApp({ userDataPath }); + const page = await waitForReadyPage(app); + + await test.step('Create two collections with distinct selected environments', async () => { + await createCollection(page, 'Collection A', firstCollectionPath); + await openCollection(page, 'Collection A'); + await createEnvironment(page, 'local-a', 'collection'); + await selectEnvironment(page, 'local-a', 'collection'); + + await createCollection(page, 'Collection B', secondCollectionPath); + await openCollection(page, 'Collection B'); + await createEnvironment(page, 'local-b', 'collection'); + await selectEnvironment(page, 'local-b', 'collection'); + }); + + await test.step('Switch back to first collection and verify environment did not drift', async () => { + await openCollection(page, 'Collection A'); + await expect(page.locator('.current-environment')).toContainText('local-a'); + await openCollection(page, 'Collection B'); + await expect(page.locator('.current-environment')).toContainText('local-b'); + }); + + await test.step('Close app and assert snapshot stores both environments', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + + const snapshot = readSnapshot(userDataPath); + expect(snapshot).not.toBeNull(); + + const collections = Array.isArray(snapshot?.collections) ? snapshot.collections : []; + const firstEntry = collections.find((collection: any) => collection?.pathname === firstCollectionRoot); + const secondEntry = collections.find((collection: any) => collection?.pathname === secondCollectionRoot); + + expect(firstEntry?.selectedEnvironment).toBe('local-a'); + expect(secondEntry?.selectedEnvironment).toBe('local-b'); + expect(firstEntry?.environmentPath).toContain(path.join('environments', 'local-a')); + expect(secondEntry?.environmentPath).toContain(path.join('environments', 'local-b')); + }); + + await test.step('Restart app and verify both selections are still restored', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await waitForReadyPage(app2); + + await openCollection(page2, 'Collection A'); + await expect(page2.locator('.current-environment')).toContainText('local-a'); + + await openCollection(page2, 'Collection B'); + await expect(page2.locator('.current-environment')).toContainText('local-b'); + + await closeElectronApp(app2); + }); + }); + + test('keeps selected environments for three collections across delayed switches and snapshot updates', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-env-persistence-three'); + const firstCollectionPath = await createTmpDir('snap-col-a-three'); + const secondCollectionPath = await createTmpDir('snap-col-b-three'); + const thirdCollectionPath = await createTmpDir('snap-col-c-three'); + const firstCollectionRoot = path.join(firstCollectionPath, 'Collection A'); + const secondCollectionRoot = path.join(secondCollectionPath, 'Collection B'); + const thirdCollectionRoot = path.join(thirdCollectionPath, 'Collection C'); + + const app = await launchElectronApp({ userDataPath }); + const page = await waitForReadyPage(app); + + await test.step('Create three collections with distinct selected environments', async () => { + await createCollection(page, 'Collection A', firstCollectionPath); + await openCollection(page, 'Collection A'); + await createEnvironment(page, 'local-a', 'collection'); + await selectEnvironment(page, 'local-a', 'collection'); + + await createCollection(page, 'Collection B', secondCollectionPath); + await openCollection(page, 'Collection B'); + await createEnvironment(page, 'local-b', 'collection'); + await selectEnvironment(page, 'local-b', 'collection'); + + await createCollection(page, 'Collection C', thirdCollectionPath); + await openCollection(page, 'Collection C'); + await createEnvironment(page, 'local-c', 'collection'); + await selectEnvironment(page, 'local-c', 'collection'); + }); + + await test.step('Switch to each collection with delays and verify selected environment stays correct', async () => { + await openCollection(page, 'Collection A'); + await expect(page.locator('.current-environment')).toContainText('local-a'); + + await openCollection(page, 'Collection B'); + await expect(page.locator('.current-environment')).toContainText('local-b'); + + await openCollection(page, 'Collection C'); + await expect(page.locator('.current-environment')).toContainText('local-c'); + }); + + await test.step('Close app and assert snapshot stores all three environments', async () => { + await closeElectronApp(app); + + const snapshot = readSnapshot(userDataPath); + expect(snapshot).not.toBeNull(); + + const collections = Array.isArray(snapshot?.collections) ? snapshot.collections : []; + const firstEntry = collections.find((collection: any) => collection?.pathname === firstCollectionRoot); + const secondEntry = collections.find((collection: any) => collection?.pathname === secondCollectionRoot); + const thirdEntry = collections.find((collection: any) => collection?.pathname === thirdCollectionRoot); + + expect(firstEntry?.selectedEnvironment).toBe('local-a'); + expect(secondEntry?.selectedEnvironment).toBe('local-b'); + expect(thirdEntry?.selectedEnvironment).toBe('local-c'); + expect(firstEntry?.environmentPath).toContain(path.join('environments', 'local-a')); + expect(secondEntry?.environmentPath).toContain(path.join('environments', 'local-b')); + expect(thirdEntry?.environmentPath).toContain(path.join('environments', 'local-c')); + }); + + await test.step('Restart app, switch through collections with delays, and verify all selections are restored', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await waitForReadyPage(app2); + + await openCollection(page2, 'Collection A'); + await expect(page2.locator('.current-environment')).toContainText('local-a'); + await page2.waitForTimeout(2000); + + await openCollection(page2, 'Collection B'); + await expect(page2.locator('.current-environment')).toContainText('local-b'); + await page2.waitForTimeout(2000); + + await openCollection(page2, 'Collection C'); + await expect(page2.locator('.current-environment')).toContainText('local-c'); + await page2.waitForTimeout(2000); + + await closeElectronApp(app2); + + const updatedSnapshot = readSnapshot(userDataPath); + expect(updatedSnapshot).not.toBeNull(); + + const updatedCollections = Array.isArray(updatedSnapshot?.collections) ? updatedSnapshot.collections : []; + const firstUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === firstCollectionRoot); + const secondUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === secondCollectionRoot); + const thirdUpdatedEntry = updatedCollections.find((collection: any) => collection?.pathname === thirdCollectionRoot); + + expect(firstUpdatedEntry?.selectedEnvironment).toBe('local-a'); + expect(secondUpdatedEntry?.selectedEnvironment).toBe('local-b'); + expect(thirdUpdatedEntry?.selectedEnvironment).toBe('local-c'); + }); + }); +}); diff --git a/tests/snapshots/environment/fixtures/collection/bruno.json b/tests/snapshots/environment/fixtures/collection/bruno.json new file mode 100644 index 000000000..8938bcb89 --- /dev/null +++ b/tests/snapshots/environment/fixtures/collection/bruno.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "name": "migration-collection", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ], + "filesCount": 1 +} \ No newline at end of file diff --git a/tests/snapshots/environment/fixtures/collection/environments/local.bru b/tests/snapshots/environment/fixtures/collection/environments/local.bru new file mode 100644 index 000000000..2fe3d0fd2 --- /dev/null +++ b/tests/snapshots/environment/fixtures/collection/environments/local.bru @@ -0,0 +1,4 @@ +vars { + collectionEnvVar: hello + ~collectionEnvVarDisabled: there +} diff --git a/tests/snapshots/environment/fixtures/collection/request.bru b/tests/snapshots/environment/fixtures/collection/request.bru new file mode 100644 index 000000000..ab84deda6 --- /dev/null +++ b/tests/snapshots/environment/fixtures/collection/request.bru @@ -0,0 +1,9 @@ +meta { + name: http-request + type: http + seq: 1 +} + +get { + url: http://localhost:8081/ping +} \ No newline at end of file diff --git a/tests/snapshots/environment/init-user-data/preferences.json b/tests/snapshots/environment/init-user-data/preferences.json new file mode 100644 index 000000000..a5777bb3f --- /dev/null +++ b/tests/snapshots/environment/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/snapshots/environment/fixtures/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/snapshots/environment/init-user-data/ui-state-snapshot.json b/tests/snapshots/environment/init-user-data/ui-state-snapshot.json new file mode 100644 index 000000000..fc3cea86d --- /dev/null +++ b/tests/snapshots/environment/init-user-data/ui-state-snapshot.json @@ -0,0 +1,8 @@ +{ + "collections": [ + { + "pathname": "{{projectRoot}}/tests/snapshots/environment/fixtures/collection", + "selectedEnvironment": "local" + } + ] +} diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index c926bb91c..8be3827ad 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,9 +1,18 @@ -import { test, expect, Page } from '../../../playwright'; +import { test, expect, Page, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright'; import process from 'node:process'; import { buildCommonLocators, buildScriptErrorLocators } from './locators'; type SandboxMode = 'safe' | 'developer'; +type WaitForAppReadyOptions = { + timeout?: number; +}; + +const waitForReadyPage = ( + app: ElectronApplication, + options: WaitForAppReadyOptions = {} +) => waitForReadyPageImpl(app, options); + /** * Close all collections * @param page - The page object @@ -1274,6 +1283,7 @@ const openExampleFromSidebar = async (page: Page, requestName: string, exampleNa }; export { + waitForReadyPage, closeAllCollections, openCollection, createCollection,