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
This commit is contained in:
Sid
2026-05-20 22:25:06 +05:30
committed by GitHub
parent cdba12387e
commit 659e02ac44
13 changed files with 371 additions and 7 deletions

View File

@@ -6,7 +6,7 @@ class ErrorBoundary extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false, clearCaches: false };
} }
componentDidMount() { componentDidMount() {
@@ -21,6 +21,10 @@ class ErrorBoundary extends React.Component {
this.setState({ hasError: true, error, errorInfo }); this.setState({ hasError: true, error, errorInfo });
} }
async clearCache() {
await window.ipcRenderer.invoke('main:cache-clear');
}
returnToApp() { returnToApp() {
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('open-file'); ipcRenderer.invoke('open-file');
@@ -36,7 +40,7 @@ class ErrorBoundary extends React.Component {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex text-center justify-center p-20 h-full"> <div className="flex text-center justify-center p-10 h-full">
<div className="bg-white rounded-lg p-10 w-full"> <div className="bg-white rounded-lg p-10 w-full">
<div className="m-auto" style={{ width: '256px' }}> <div className="m-auto" style={{ width: '256px' }}>
<Bruno width={256} /> <Bruno width={256} />
@@ -63,8 +67,30 @@ class ErrorBoundary extends React.Component {
Return to App Return to App
</button> </button>
<div className="text-red-500 mt-3"> <div className="mt-5 pt-4 border-t border-gray-100 flex flex-col items-center gap-2">
<a href="" className="hover:underline cursor-pointer" onClick={this.forceQuit}> <label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer select-none hover:text-gray-800 transition">
<input
type="checkbox"
checked={this.state.clearCaches}
onChange={(e) => this.setState({ clearCaches: e.target.checked })}
className="cursor-pointer"
/>
Clear caches on quit
</label>
<a
href=""
className="text-sm text-red-400 border border-red-400 hover:text-red-600 px-4 py-2 rounded transition cursor-pointer"
onClick={async (e) => {
e.preventDefault();
try {
if (this.state.clearCaches) {
await this.clearCache();
}
} finally {
this.forceQuit();
}
}}
>
Force Quit Force Quit
</a> </a>
</div> </div>

View File

@@ -157,6 +157,9 @@ const serializeSnapshot = async (state) => {
const workspacePathname = activeWorkspace?.pathname || ''; const workspacePathname = activeWorkspace?.pathname || '';
const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname); const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname);
const existingCollection = (collectionSnapshotKey && existingSnapshotLookups.collectionsByWorkspaceAndPath?.[collectionSnapshotKey])
|| existingSnapshotLookups.collectionsByPath?.[normalizedPath]
|| null;
if (collectionSnapshotKey) { if (collectionSnapshotKey) {
serializedCollectionKeys.add(collectionSnapshotKey); serializedCollectionKeys.add(collectionSnapshotKey);
} }
@@ -174,7 +177,17 @@ const serializeSnapshot = async (state) => {
); );
const selectedEnvironment = (collection.environments || []).find((env) => env.uid === collection.activeEnvironmentUid); 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({ snapshot.collections.push({
pathname: collection.pathname, pathname: collection.pathname,
@@ -184,7 +197,7 @@ const serializeSnapshot = async (state) => {
global: globalEnvironments.activeGlobalEnvironmentUid || '' global: globalEnvironments.activeGlobalEnvironmentUid || ''
}, },
environmentPath, environmentPath,
selectedEnvironment: selectedEnvironment?.name || '', selectedEnvironment: selectedEnvironmentName,
isOpen: !collection.collapsed, isOpen: !collection.collapsed,
isMounted: collection.mountStatus === 'mounted', isMounted: collection.mountStatus === 'mounted',
activeTab: serializeActiveTab(activeTabInCollection, collection), activeTab: serializeActiveTab(activeTabInCollection, collection),

View File

@@ -471,6 +471,11 @@ app.on('ready', async () => {
registerSystemMonitorIpc(mainWindow, systemMonitor); registerSystemMonitorIpc(mainWindow, systemMonitor);
registerGitIpc(mainWindow); registerGitIpc(mainWindow);
registerOpenAPISyncIpc(mainWindow); registerOpenAPISyncIpc(mainWindow);
// Internal delegator
ipcMain.handle('main:cache-clear', async () => {
ipcMain.emit('internal:snapshot:reset');
});
}); });
// Quit the app once all windows are closed // Quit the app once all windows are closed

View File

@@ -10,6 +10,14 @@ const registerSnapshotIpc = () => {
return snapshotManager.getTabs(collectionPathname, workspacePathname); 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) => { ipcMain.handle('renderer:snapshot:save', async (event, data) => {
return snapshotManager.saveSnapshot(data); return snapshotManager.saveSnapshot(data);
}); });

View File

@@ -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) { setCollection(pathname, data) {
const normalizedPath = normalizeLookupKey(pathname); const normalizedPath = normalizeLookupKey(pathname);
if (!normalizedPath) { if (!normalizedPath) {

View File

@@ -31,6 +31,28 @@ function isTracingEnabled(testInfo: TestInfo): boolean {
return !!(testInfo as any)._tracing.traceOptions(); 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<Page> {
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( async function usePageWithTracing(
context: BrowserContext, context: BrowserContext,
page: Page, page: Page,

View File

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

View File

@@ -0,0 +1,10 @@
{
"version": "1",
"name": "migration-collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"filesCount": 1
}

View File

@@ -0,0 +1,4 @@
vars {
collectionEnvVar: hello
~collectionEnvVarDisabled: there
}

View File

@@ -0,0 +1,9 @@
meta {
name: http-request
type: http
seq: 1
}
get {
url: http://localhost:8081/ping
}

View File

@@ -0,0 +1,12 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/snapshots/environment/fixtures/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -0,0 +1,8 @@
{
"collections": [
{
"pathname": "{{projectRoot}}/tests/snapshots/environment/fixtures/collection",
"selectedEnvironment": "local"
}
]
}

View File

@@ -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 process from 'node:process';
import { buildCommonLocators, buildScriptErrorLocators } from './locators'; import { buildCommonLocators, buildScriptErrorLocators } from './locators';
type SandboxMode = 'safe' | 'developer'; type SandboxMode = 'safe' | 'developer';
type WaitForAppReadyOptions = {
timeout?: number;
};
const waitForReadyPage = (
app: ElectronApplication,
options: WaitForAppReadyOptions = {}
) => waitForReadyPageImpl(app, options);
/** /**
* Close all collections * Close all collections
* @param page - The page object * @param page - The page object
@@ -1274,6 +1283,7 @@ const openExampleFromSidebar = async (page: Page, requestName: string, exampleNa
}; };
export { export {
waitForReadyPage,
closeAllCollections, closeAllCollections,
openCollection, openCollection,
createCollection, createCollection,