From 3c2470b7cdb510af2f4754da55ab5dbf40382783 Mon Sep 17 00:00:00 2001 From: Nikolay Bachiyski Date: Fri, 13 Mar 2026 12:40:44 +0100 Subject: [PATCH 1/2] Register CLI-started sites on update events Sites started outside the desktop app are announced through the CLI\nevents stream as site-updated events. The subscriber only registered\nmissing sites on site-created, so a site started via studio ai or\nstudio site start could be dropped before the renderer ever saw it.\n\nRegister missing sites on updated events too and cover the path with a\nsubscriber regression test. --- .../modules/cli/lib/cli-events-subscriber.ts | 20 ++-- .../lib/tests/cli-events-subscriber.test.ts | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts 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' ); + } ); +} ); From 3f3345fe6935911b62c95ea91fd2e4a33c1c5927 Mon Sep 17 00:00:00 2001 From: Nikolay Bachiyski Date: Fri, 13 Mar 2026 12:40:47 +0100 Subject: [PATCH 2/2] Ignore devtools worker startup failures in development Electron can fail to start the React and Redux DevTools service worker\nwhen Studio boots in development. That rejection was unhandled, which\nmade npm start noisy even though the app could continue to run.\n\nWrap development extension setup in a guard, log a warning, and keep\nbooting when the worker fails to start. --- apps/studio/src/index.ts | 14 +++++++-- apps/studio/src/tests/index.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) 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/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' } );