diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index bf70bc274..ab62c48fe 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -3,7 +3,7 @@ const path = require('path'); const { execSync } = require('node:child_process'); const isDev = require('electron-is-dev'); const os = require('os'); -const { initializeShellEnv } = require('@usebruno/requests'); +const { initializeShellEnv, waitForShellEnv } = require('./store/shell-env-state'); const { percentageToZoomLevel } = require('@usebruno/common'); if (isDev) { @@ -181,8 +181,7 @@ if (useSingleInstance && !gotTheLock) { // Prepare the renderer once the app is ready app.on('ready', async () => { - // Ensure shell environment is loaded before any operations that need it - await initializeShellEnv(); + initializeShellEnv(); if (isDev) { const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); @@ -203,8 +202,10 @@ app.on('ready', async () => { // Initialize system proxy cache early (non-blocking) const { fetchSystemProxy } = require('./store/system-proxy'); - fetchSystemProxy().catch((err) => { - console.warn('Failed to initialize system proxy cache:', err); + waitForShellEnv().then(() => { + fetchSystemProxy().catch((err) => { + console.warn('Failed to initialize system proxy cache:', err); + }); }); Menu.setApplicationMenu(menu); diff --git a/packages/bruno-electron/src/store/shell-env-state.js b/packages/bruno-electron/src/store/shell-env-state.js new file mode 100644 index 000000000..95773f8d1 --- /dev/null +++ b/packages/bruno-electron/src/store/shell-env-state.js @@ -0,0 +1,27 @@ +const { initializeShellEnv: _initializeShellEnv } = require('@usebruno/requests'); + +const TIMEOUT_MS = 60_000; + +let _promise = null; + +const _initWithTimeout = () => { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + _promise = null; + reject(new Error('Shell environment initialization timed out')); + }, TIMEOUT_MS); + }); + return Promise.race([_initializeShellEnv(), timeout]).finally(() => clearTimeout(timer)); +}; + +const initializeShellEnv = () => { + if (!_promise) _promise = _initWithTimeout(); +}; + +const waitForShellEnv = () => { + if (!_promise) _promise = _initWithTimeout(); + return _promise; +}; + +module.exports = { initializeShellEnv, waitForShellEnv }; diff --git a/packages/bruno-electron/src/store/tests/shell-env-state.spec.js b/packages/bruno-electron/src/store/tests/shell-env-state.spec.js new file mode 100644 index 000000000..aeb40f00d --- /dev/null +++ b/packages/bruno-electron/src/store/tests/shell-env-state.spec.js @@ -0,0 +1,105 @@ +let mockInitialize; + +jest.mock('@usebruno/requests', () => ({ + initializeShellEnv: (...args) => mockInitialize(...args) +})); + +describe('shell-env-state', () => { + let initializeShellEnv, waitForShellEnv; + + beforeEach(() => { + jest.resetModules(); + mockInitialize = jest.fn(() => Promise.resolve()); + ({ initializeShellEnv, waitForShellEnv } = require('../shell-env-state')); + }); + + describe('initializeShellEnv', () => { + it('calls the underlying initializer exactly once on first call', () => { + initializeShellEnv(); + initializeShellEnv(); + initializeShellEnv(); + initializeShellEnv(); + initializeShellEnv(); + initializeShellEnv(); + initializeShellEnv(); + expect(mockInitialize).toHaveBeenCalledTimes(1); + }); + + it('returns undefined (fire-and-forget)', () => { + const result = initializeShellEnv(); + expect(result).toBeUndefined(); + }); + }); + + describe('waitForShellEnv', () => { + it('returns a promise', () => { + const result = waitForShellEnv(); + expect(result).toBeInstanceOf(Promise); + }); + + it('resolves when the underlying promise resolves', async () => { + mockInitialize = jest.fn(() => Promise.resolve('shell-ready')); + + await expect(waitForShellEnv()).resolves.toBe('shell-ready'); + }); + + it('returns the same promise on repeated calls', () => { + const p1 = waitForShellEnv(); + const p2 = waitForShellEnv(); + expect(p1).toBe(p2); + }); + + it('does not reinitialize if initializeShellEnv was already called', () => { + initializeShellEnv(); + waitForShellEnv(); + expect(mockInitialize).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection from the underlying initializer', async () => { + const err = new Error('shell init failed'); + mockInitialize = jest.fn(() => Promise.reject(err)); + + await expect(waitForShellEnv()).rejects.toThrow('shell init failed'); + }); + + describe('timeout', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('rejects after 60 seconds', async () => { + mockInitialize = jest.fn(() => new Promise(() => {})); // never resolves + ({ waitForShellEnv } = require('../shell-env-state')); + + const p = waitForShellEnv(); + jest.advanceTimersByTime(60_000); + + await expect(p).rejects.toThrow('Shell environment initialization timed out'); + }); + + it('resets the promise after timeout so next call retries', async () => { + mockInitialize = jest.fn(() => new Promise(() => {})); + ({ initializeShellEnv, waitForShellEnv } = require('../shell-env-state')); + + const p = waitForShellEnv(); + jest.advanceTimersByTime(60_000); + await expect(p).rejects.toThrow('timed out'); + + // After timeout _promise is null — next call should reinitialize + mockInitialize = jest.fn(() => Promise.resolve('retry-ok')); + const p2 = waitForShellEnv(); + await expect(p2).resolves.toBe('retry-ok'); + expect(mockInitialize).toHaveBeenCalledTimes(1); + }); + + it('does not time out if the initializer resolves in time', async () => { + mockInitialize = jest.fn(() => Promise.resolve('fast')); + ({ waitForShellEnv } = require('../shell-env-state')); + + const p = waitForShellEnv(); + jest.advanceTimersByTime(59_999); + + await expect(p).resolves.toBe('fast'); + }); + }); + }); +});