diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index a44d16f30c..afae15c337 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -148,6 +148,16 @@ function launchExtensionBackgroundWorkers( appSession = session.defaultSession ) ); } +async function setupDevelopmentExtensions(): Promise< void > { + try { + await installExtension( REACT_DEVELOPER_TOOLS ); + await installExtension( REDUX_DEVTOOLS ); + await launchExtensionBackgroundWorkers(); + } catch ( error ) { + console.warn( 'Failed to initialize development extensions:', error ); + } +} + async function appBoot() { app.setName( packageJson.productName ); @@ -263,9 +273,7 @@ async function appBoot() { app.on( 'ready', async () => { const locale = await getUserLocaleWithFallback(); if ( process.env.NODE_ENV === 'development' ) { - await installExtension( REACT_DEVELOPER_TOOLS ); - await installExtension( REDUX_DEVTOOLS ); - await launchExtensionBackgroundWorkers(); + await setupDevelopmentExtensions(); } console.log( `App version: ${ app.getVersion() }` ); diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index f5338f73af..4012defdef 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -35,26 +35,22 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > return; } - // Only register new sites on CREATED events to prevent duplicates + let server = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); + if ( ! server ) { + // Sites started outside the desktop app are announced as UPDATED events. + // Register them here so startup sync and external CLI actions surface correctly. + server = SiteServer.register( siteDetailsToServerDetails( site, running ) ); + } + if ( eventType === SITE_EVENTS.CREATED ) { - const existingServer = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); - if ( ! existingServer ) { - SiteServer.register( siteDetailsToServerDetails( site, running ) ); - } // Don't send to renderer if site is being created by UI (createSite IPC will handle it) - if ( existingServer?.hasOngoingOperation ) { + if ( server.hasOngoingOperation ) { return; } void sendIpcEventToRenderer( 'site-event', event ); return; } - // For UPDATED events, only update if the site already exists - const server = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); - if ( ! server ) { - return; - } - server.details = siteDetailsToServerDetails( site, running, server.details ); if ( server.server && site.url ) { diff --git a/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts new file mode 100644 index 0000000000..8bebf613f0 --- /dev/null +++ b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { EventEmitter } from 'node:events'; +import { SITE_EVENTS } from '@studio/common/lib/site-events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sendIpcEventToRenderer = vi.fn(); +const executeCliCommand = vi.fn(); +const register = vi.fn(); +const unregister = vi.fn(); +const get = vi.fn(); +const getByPath = vi.fn(); + +let currentEmitter: EventEmitter; +let currentChildProcess: { kill: ReturnType< typeof vi.fn > }; + +vi.mock( 'src/ipc-utils', () => ( { + sendIpcEventToRenderer, +} ) ); + +vi.mock( 'src/modules/cli/lib/execute-command', () => ( { + executeCliCommand, +} ) ); + +vi.mock( 'src/site-server', () => ( { + SiteServer: { + register, + unregister, + get, + getByPath, + }, +} ) ); + +describe( 'cli-events-subscriber', () => { + beforeEach( () => { + vi.resetModules(); + vi.clearAllMocks(); + + currentEmitter = new EventEmitter(); + currentChildProcess = { kill: vi.fn() }; + executeCliCommand.mockReturnValue( [ currentEmitter, currentChildProcess ] ); + get.mockReturnValue( undefined ); + getByPath.mockReturnValue( undefined ); + register.mockImplementation( ( details ) => ( { + details, + hasOngoingOperation: false, + server: { url: details.url }, + } ) ); + } ); + + it( 'registers missing sites from UPDATED events so external CLI starts appear in the app', async () => { + const { startCliEventsSubscriber, stopCliEventsSubscriber } = await import( + 'src/modules/cli/lib/cli-events-subscriber' + ); + + const startPromise = startCliEventsSubscriber(); + currentEmitter.emit( 'started' ); + await startPromise; + + const siteEvent = { + event: SITE_EVENTS.UPDATED, + siteId: 'site-1', + running: true, + site: { + id: 'site-1', + name: 'AI Site', + path: '/sites/ai-site', + port: 8881, + url: 'http://localhost:8881', + phpVersion: '8.3', + }, + }; + + currentEmitter.emit( 'data', { + data: { + action: 'keyValuePair', + key: 'site-event', + value: JSON.stringify( siteEvent ), + }, + } ); + + await vi.waitFor( () => { + expect( register ).toHaveBeenCalledWith( { + ...siteEvent.site, + running: true, + } ); + } ); + + expect( sendIpcEventToRenderer ).toHaveBeenCalledWith( 'site-event', siteEvent ); + + stopCliEventsSubscriber(); + expect( currentChildProcess.kill ).toHaveBeenCalledWith( 'SIGKILL' ); + } ); +} ); diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index c3acb1455e..6470533a6a 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -178,6 +178,50 @@ describe( 'App initialization', () => { await expect( import( '../index' ) ).resolves.toBeDefined(); } ); + it( 'should continue booting when development extension workers fail to start', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const warnSpy = vi.spyOn( console, 'warn' ).mockImplementation( () => {} ); + + try { + const { mockedEvents } = mockElectron(); + vi.resetModules(); + + const { session } = await import( 'electron' ); + const serviceWorkerError = new Error( 'Failed to start service worker.' ); + vi.mocked( session.defaultSession.extensions.getAllExtensions ).mockReturnValue( [ + { + id: 'test-extension', + manifest: { + manifest_version: 3, + background: { + service_worker: 'background.js', + }, + }, + name: 'Test Extension', + path: '/mock/extensions/test-extension', + url: 'chrome-extension://test-extension/', + version: '1.0.0', + }, + ] ); + vi.mocked( session.defaultSession.serviceWorkers.startWorkerForScope ).mockRejectedValue( + serviceWorkerError + ); + + await import( '../index' ); + await expect( mockedEvents.ready() ).resolves.toBeUndefined(); + expect( warnSpy ).toHaveBeenCalledWith( + 'Failed to initialize development extensions:', + serviceWorkerError + ); + + await mockedEvents[ 'will-quit' ]( { preventDefault: vi.fn() } ); + } finally { + process.env.NODE_ENV = originalNodeEnv; + warnSpy.mockRestore(); + } + } ); + it( 'should handle authentication deep links', async () => { const originalProcessPlatform = process.platform; Object.defineProperty( process, 'platform', { value: 'darwin' } );