From 6b1912ccfa89f73f3f4d214c12db54e43e1bab20 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 7 May 2026 12:05:11 -0400 Subject: [PATCH 1/2] Improve local wp-admin responsiveness --- apps/studio/src/lib/background-tasks.ts | 262 ++++++++++++++++++ apps/studio/src/lib/capture-site-thumbnail.ts | 55 ++-- apps/studio/src/lib/site-maintenance-tasks.ts | 41 +++ .../src/lib/tests/background-tasks.test.ts | 154 ++++++++++ .../lib/tests/capture-site-thumbnail.test.ts | 113 ++++++++ .../lib/tests/site-maintenance-tasks.test.ts | 81 ++++++ .../modules/cli/lib/cli-events-subscriber.ts | 7 +- .../lib/tests/cli-events-subscriber.test.ts | 105 +++++++ tools/common/lib/mu-plugins.ts | 129 +++++++++ tools/common/lib/tests/mu-plugins.test.ts | 29 ++ 10 files changed, 950 insertions(+), 26 deletions(-) create mode 100644 apps/studio/src/lib/background-tasks.ts create mode 100644 apps/studio/src/lib/site-maintenance-tasks.ts create mode 100644 apps/studio/src/lib/tests/background-tasks.test.ts create mode 100644 apps/studio/src/lib/tests/capture-site-thumbnail.test.ts create mode 100644 apps/studio/src/lib/tests/site-maintenance-tasks.test.ts create mode 100644 apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts diff --git a/apps/studio/src/lib/background-tasks.ts b/apps/studio/src/lib/background-tasks.ts new file mode 100644 index 0000000000..f0340c7cfa --- /dev/null +++ b/apps/studio/src/lib/background-tasks.ts @@ -0,0 +1,262 @@ +import * as Sentry from '@sentry/electron/main'; + +export type BackgroundTaskStatus = + | 'enqueued' + | 'deduped' + | 'started' + | 'succeeded' + | 'failed' + | 'skipped' + | 'cancelled'; + +export interface BackgroundTaskEvent { + status: BackgroundTaskStatus; + name: string; + scope?: string; + key: string; + trigger?: string; + durationMs?: number; + queueDelayMs?: number; + error?: unknown; +} + +export interface BackgroundTaskContext { + signal: AbortSignal; +} + +export interface BackgroundTaskOptions { + name: string; + scope?: string; + dedupeKey?: string; + trigger?: string; + delayMs?: number; + concurrencyGroup?: string; + requiresRunning?: () => boolean; + run: ( context: BackgroundTaskContext ) => Promise< void >; +} + +interface QueuedTask { + key: string; + options: BackgroundTaskOptions; + controller: AbortController; + enqueuedAt: number; + timer: ReturnType< typeof setTimeout > | null; + promise: Promise< void >; + resolve: () => void; + reject: ( error: unknown ) => void; +} + +export interface BackgroundTaskSchedulerOptions { + onEvent?: ( event: BackgroundTaskEvent ) => void; + now?: () => number; +} + +export class BackgroundTaskScheduler { + private tasks = new Map< string, QueuedTask >(); + private runningGroups = new Set< string >(); + private groupQueues = new Map< string, QueuedTask[] >(); + private readonly onEvent?: ( event: BackgroundTaskEvent ) => void; + private readonly now: () => number; + + constructor( options: BackgroundTaskSchedulerOptions = {} ) { + this.onEvent = options.onEvent; + this.now = options.now ?? ( () => Date.now() ); + } + + enqueue( options: BackgroundTaskOptions ): Promise< void > { + const key = this.getTaskKey( options ); + const existing = this.tasks.get( key ); + if ( existing ) { + this.emit( { status: 'deduped', ...this.eventBase( existing ) } ); + return existing.promise; + } + + const controller = new AbortController(); + let resolve!: () => void; + let reject!: ( error: unknown ) => void; + const promise = new Promise< void >( ( promiseResolve, promiseReject ) => { + resolve = promiseResolve; + reject = promiseReject; + } ); + + const task: QueuedTask = { + key, + options, + controller, + enqueuedAt: this.now(), + timer: null, + promise, + resolve, + reject, + }; + + this.tasks.set( key, task ); + this.emit( { status: 'enqueued', ...this.eventBase( task ) } ); + + const delayMs = options.delayMs ?? 0; + if ( delayMs > 0 ) { + task.timer = setTimeout( () => this.startTask( task ), delayMs ); + } else { + this.startTask( task ); + } + + return promise; + } + + cancel( + keyOrOptions: string | Pick< BackgroundTaskOptions, 'name' | 'scope' | 'dedupeKey' > + ): boolean { + const key = typeof keyOrOptions === 'string' ? keyOrOptions : this.getTaskKey( keyOrOptions ); + const task = this.tasks.get( key ); + if ( ! task ) { + return false; + } + + if ( task.timer ) { + clearTimeout( task.timer ); + } + this.removeFromGroupQueue( task ); + task.controller.abort(); + this.tasks.delete( key ); + this.emit( { status: 'cancelled', ...this.eventBase( task ) } ); + task.resolve(); + return true; + } + + getPendingCount(): number { + return this.tasks.size; + } + + private startTask( task: QueuedTask ): void { + task.timer = null; + const group = task.options.concurrencyGroup; + if ( group && this.runningGroups.has( group ) ) { + const queue = this.groupQueues.get( group ) ?? []; + queue.push( task ); + this.groupQueues.set( group, queue ); + return; + } + + if ( group ) { + this.runningGroups.add( group ); + } + + void this.runTask( task ); + } + + private async runTask( task: QueuedTask ): Promise< void > { + if ( task.controller.signal.aborted ) { + this.tasks.delete( task.key ); + this.releaseConcurrencyGroup( task ); + task.resolve(); + return; + } + + if ( task.options.requiresRunning && ! task.options.requiresRunning() ) { + this.tasks.delete( task.key ); + this.releaseConcurrencyGroup( task ); + this.emit( { status: 'skipped', ...this.eventBase( task ) } ); + task.resolve(); + return; + } + + const startedAt = this.now(); + this.emit( { + status: 'started', + ...this.eventBase( task ), + queueDelayMs: startedAt - task.enqueuedAt, + } ); + + try { + await task.options.run( { signal: task.controller.signal } ); + this.emit( { + status: 'succeeded', + ...this.eventBase( task ), + durationMs: this.now() - startedAt, + } ); + task.resolve(); + } catch ( error ) { + Sentry.captureException( error ); + this.emit( { + status: 'failed', + ...this.eventBase( task ), + durationMs: this.now() - startedAt, + error, + } ); + task.reject( error ); + } finally { + this.tasks.delete( task.key ); + this.releaseConcurrencyGroup( task ); + } + } + + private releaseConcurrencyGroup( task: QueuedTask ): void { + const group = task.options.concurrencyGroup; + if ( ! group ) { + return; + } + + const queue = this.groupQueues.get( group ); + const next = queue?.shift(); + if ( queue && queue.length === 0 ) { + this.groupQueues.delete( group ); + } + + if ( next ) { + void this.runTask( next ); + return; + } + + this.runningGroups.delete( group ); + } + + private removeFromGroupQueue( task: QueuedTask ): void { + const group = task.options.concurrencyGroup; + if ( ! group ) { + return; + } + + const queue = this.groupQueues.get( group ); + if ( ! queue ) { + return; + } + + const nextQueue = queue.filter( ( queuedTask ) => queuedTask !== task ); + if ( nextQueue.length === 0 ) { + this.groupQueues.delete( group ); + return; + } + + this.groupQueues.set( group, nextQueue ); + } + + private getTaskKey( + options: Pick< BackgroundTaskOptions, 'name' | 'scope' | 'dedupeKey' > + ): string { + return options.dedupeKey ?? [ options.scope, options.name ].filter( Boolean ).join( ':' ); + } + + private eventBase( task: QueuedTask ) { + return { + name: task.options.name, + scope: task.options.scope, + key: task.key, + trigger: task.options.trigger, + }; + } + + private emit( event: BackgroundTaskEvent ): void { + this.onEvent?.( event ); + } +} + +export const backgroundTasks = new BackgroundTaskScheduler( { + onEvent( event ) { + if ( event.status === 'failed' ) { + console.error( 'Background task failed', event ); + return; + } + + console.debug( 'Background task event', event ); + }, +} ); diff --git a/apps/studio/src/lib/capture-site-thumbnail.ts b/apps/studio/src/lib/capture-site-thumbnail.ts index 46f0892bf5..c6bf0d62cf 100644 --- a/apps/studio/src/lib/capture-site-thumbnail.ts +++ b/apps/studio/src/lib/capture-site-thumbnail.ts @@ -1,32 +1,37 @@ -import { sequential } from '@studio/common/lib/sequential'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; +import { backgroundTasks } from 'src/lib/background-tasks'; import { getImageData } from 'src/lib/get-image-data'; import { SiteServer } from 'src/site-server'; import { getSiteThumbnailPath } from 'src/storage/paths'; -// Capture and cache a site's thumbnail. Uses sequential with deduplicateKey to -// prevent concurrent BrowserWindow creation for the same site and to serialize -// captures across all sites (one at a time). -export const captureSiteThumbnail = sequential< [ string, boolean? ], void >( - async ( id: string, emitLoadingEvent = true ) => { - const server = SiteServer.get( id ); - if ( ! server || ! server.details.running ) { - return; - } +// Capture and cache a site's thumbnail. Routed through the background task +// scheduler to dedupe concurrent captures and keep BrowserWindow work off the +// caller's critical path. +export function captureSiteThumbnail( id: string, emitLoadingEvent = true ): Promise< void > { + return backgroundTasks.enqueue( { + name: 'capture-site-thumbnail', + scope: `site:${ id }`, + concurrencyGroup: 'site-thumbnail-capture', + requiresRunning: () => SiteServer.get( id )?.details.running === true, + run: async () => { + const server = SiteServer.get( id ); + if ( ! server || ! server.details.running ) { + return; + } - if ( emitLoadingEvent ) { - await sendIpcEventToRenderer( 'thumbnail-loading', { id } ); - } + if ( emitLoadingEvent ) { + await sendIpcEventToRenderer( 'thumbnail-loading', { id } ); + } - try { - await server.updateCachedThumbnail(); - const thumbnailPath = getSiteThumbnailPath( id ); - const thumbnailData = await getImageData( thumbnailPath ); - await sendIpcEventToRenderer( 'thumbnail-loaded', { id, imageData: thumbnailData } ); - } catch ( error ) { - await sendIpcEventToRenderer( 'thumbnail-load-error', { id } ); - console.error( `Failed to update thumbnail for server ${ id }:`, error ); - } - }, - { deduplicateKey: ( id ) => id } -); + try { + await server.updateCachedThumbnail(); + const thumbnailPath = getSiteThumbnailPath( id ); + const thumbnailData = await getImageData( thumbnailPath ); + await sendIpcEventToRenderer( 'thumbnail-loaded', { id, imageData: thumbnailData } ); + } catch ( error ) { + await sendIpcEventToRenderer( 'thumbnail-load-error', { id } ); + console.error( `Failed to update thumbnail for server ${ id }:`, error ); + } + }, + } ); +} diff --git a/apps/studio/src/lib/site-maintenance-tasks.ts b/apps/studio/src/lib/site-maintenance-tasks.ts new file mode 100644 index 0000000000..d1f8f52145 --- /dev/null +++ b/apps/studio/src/lib/site-maintenance-tasks.ts @@ -0,0 +1,41 @@ +import { backgroundTasks } from 'src/lib/background-tasks'; +import { SiteServer } from 'src/site-server'; + +const REFRESH_LOCAL_ADMIN_CHECKS_CODE = ` +require_once ABSPATH . 'wp-admin/includes/update.php'; +require_once ABSPATH . 'wp-admin/includes/dashboard.php'; +require_once ABSPATH . 'wp-admin/includes/misc.php'; +require_once ABSPATH . 'wp-admin/includes/translation-install.php'; + +wp_version_check(); +wp_update_plugins(); +wp_update_themes(); +wp_check_browser_version(); +wp_check_php_version(); +wp_get_available_translations(); +`; + +export function scheduleLocalAdminChecksRefresh( + server: SiteServer, + trigger: string = 'site-start' +): Promise< void > { + return backgroundTasks.enqueue( { + name: 'refresh-local-admin-checks', + scope: `site:${ server.details.id }`, + trigger, + delayMs: 1000, + requiresRunning: () => server.details.running, + run: async () => { + const result = await server.executeWpCliCommand( [ + 'eval', + REFRESH_LOCAL_ADMIN_CHECKS_CODE, + ] ); + + if ( result.exitCode !== 0 ) { + throw new Error( + result.stderr || result.stdout || 'Failed to refresh local admin checks.' + ); + } + }, + } ); +} diff --git a/apps/studio/src/lib/tests/background-tasks.test.ts b/apps/studio/src/lib/tests/background-tasks.test.ts new file mode 100644 index 0000000000..1648b8f2e9 --- /dev/null +++ b/apps/studio/src/lib/tests/background-tasks.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; +import { BackgroundTaskScheduler } from 'src/lib/background-tasks'; + +vi.mock( '@sentry/electron/main', () => ( { + captureException: vi.fn(), +} ) ); + +describe( 'BackgroundTaskScheduler', () => { + it( 'runs a task and emits lifecycle telemetry', async () => { + let now = 100; + const events: string[] = []; + const scheduler = new BackgroundTaskScheduler( { + now: () => now, + onEvent: ( event ) => events.push( event.status ), + } ); + + await scheduler.enqueue( { + name: 'task', + scope: 'site:1', + run: async () => { + now = 175; + }, + } ); + + expect( events ).toEqual( [ 'enqueued', 'started', 'succeeded' ] ); + expect( scheduler.getPendingCount() ).toBe( 0 ); + } ); + + it( 'deduplicates queued work by key', async () => { + vi.useFakeTimers(); + try { + const events: string[] = []; + const run = vi.fn().mockResolvedValue( undefined ); + const scheduler = new BackgroundTaskScheduler( { + onEvent: ( event ) => events.push( event.status ), + } ); + + const first = scheduler.enqueue( { + name: 'task', + scope: 'site:1', + delayMs: 100, + run, + } ); + const second = scheduler.enqueue( { + name: 'task', + scope: 'site:1', + delayMs: 100, + run, + } ); + + expect( second ).toBe( first ); + expect( events ).toEqual( [ 'enqueued', 'deduped' ] ); + + await vi.advanceTimersByTimeAsync( 100 ); + await first; + + expect( run ).toHaveBeenCalledTimes( 1 ); + } finally { + vi.useRealTimers(); + } + } ); + + it( 'skips tasks when the running guard fails', async () => { + const run = vi.fn().mockResolvedValue( undefined ); + const events: string[] = []; + const scheduler = new BackgroundTaskScheduler( { + onEvent: ( event ) => events.push( event.status ), + } ); + + await scheduler.enqueue( { + name: 'task', + scope: 'site:1', + requiresRunning: () => false, + run, + } ); + + expect( run ).not.toHaveBeenCalled(); + expect( events ).toEqual( [ 'enqueued', 'skipped' ] ); + } ); + + it( 'cancels delayed tasks before they start', async () => { + vi.useFakeTimers(); + try { + const run = vi.fn().mockResolvedValue( undefined ); + const scheduler = new BackgroundTaskScheduler(); + + const task = scheduler.enqueue( { + name: 'task', + scope: 'site:1', + delayMs: 100, + run, + } ); + + expect( scheduler.cancel( { name: 'task', scope: 'site:1' } ) ).toBe( true ); + await vi.advanceTimersByTimeAsync( 100 ); + await task; + + expect( run ).not.toHaveBeenCalled(); + expect( scheduler.getPendingCount() ).toBe( 0 ); + } finally { + vi.useRealTimers(); + } + } ); + + it( 'serializes tasks in the same concurrency group', async () => { + let finishFirst!: () => void; + const order: string[] = []; + const scheduler = new BackgroundTaskScheduler(); + + const first = scheduler.enqueue( { + name: 'first', + concurrencyGroup: 'group', + run: async () => { + order.push( 'first:start' ); + await new Promise< void >( ( resolve ) => { + finishFirst = resolve; + } ); + order.push( 'first:end' ); + }, + } ); + const second = scheduler.enqueue( { + name: 'second', + concurrencyGroup: 'group', + run: async () => { + order.push( 'second:start' ); + }, + } ); + + await Promise.resolve(); + expect( order ).toEqual( [ 'first:start' ] ); + + finishFirst(); + await Promise.all( [ first, second ] ); + + expect( order ).toEqual( [ 'first:start', 'first:end', 'second:start' ] ); + } ); + + it( 'surfaces task failures to callers', async () => { + const scheduler = new BackgroundTaskScheduler(); + const error = new Error( 'boom' ); + + await expect( + scheduler.enqueue( { + name: 'task', + run: async () => { + throw error; + }, + } ) + ).rejects.toThrow( 'boom' ); + } ); +} ); diff --git a/apps/studio/src/lib/tests/capture-site-thumbnail.test.ts b/apps/studio/src/lib/tests/capture-site-thumbnail.test.ts new file mode 100644 index 0000000000..e45539c3d1 --- /dev/null +++ b/apps/studio/src/lib/tests/capture-site-thumbnail.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; + +const mockSendIpcEventToRenderer = vi.fn().mockResolvedValue( undefined ); +const mockGetImageData = vi.fn().mockResolvedValue( 'data:image/png;base64,mock' ); + +vi.mock( 'src/ipc-utils', () => ( { + sendIpcEventToRenderer: mockSendIpcEventToRenderer, +} ) ); +vi.mock( 'src/lib/get-image-data', () => ( { + getImageData: mockGetImageData, +} ) ); +vi.mock( 'src/storage/paths', () => ( { + getSiteThumbnailPath: ( id: string ) => `/thumbs/${ id }.png`, +} ) ); + +const getMock = vi.fn(); +vi.mock( 'src/site-server', () => ( { + SiteServer: { + get: getMock, + }, +} ) ); + +describe( 'captureSiteThumbnail', () => { + beforeEach( () => { + vi.resetModules(); + vi.clearAllMocks(); + } ); + + it( 'updates a running site thumbnail and emits loading and loaded events', async () => { + const updateCachedThumbnail = vi.fn().mockResolvedValue( undefined ); + getMock.mockReturnValue( { + details: { id: 'site-1', running: true }, + updateCachedThumbnail, + } ); + + const { captureSiteThumbnail } = await import( 'src/lib/capture-site-thumbnail' ); + await captureSiteThumbnail( 'site-1' ); + + expect( updateCachedThumbnail ).toHaveBeenCalledTimes( 1 ); + expect( mockGetImageData ).toHaveBeenCalledWith( '/thumbs/site-1.png' ); + expect( mockSendIpcEventToRenderer ).toHaveBeenCalledWith( 'thumbnail-loading', { + id: 'site-1', + } ); + expect( mockSendIpcEventToRenderer ).toHaveBeenCalledWith( 'thumbnail-loaded', { + id: 'site-1', + imageData: 'data:image/png;base64,mock', + } ); + } ); + + it( 'deduplicates concurrent captures for the same site', async () => { + let resolveCapture!: () => void; + const updateCachedThumbnail = vi.fn( + () => new Promise< void >( ( resolve ) => ( resolveCapture = resolve ) ) + ); + getMock.mockReturnValue( { + details: { id: 'site-1', running: true }, + updateCachedThumbnail, + } ); + + const { captureSiteThumbnail } = await import( 'src/lib/capture-site-thumbnail' ); + const first = captureSiteThumbnail( 'site-1' ); + const second = captureSiteThumbnail( 'site-1' ); + + await Promise.resolve(); + resolveCapture(); + await Promise.all( [ first, second ] ); + + expect( updateCachedThumbnail ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'serializes captures across different sites', async () => { + let resolveFirst!: () => void; + const calls: string[] = []; + const firstCapture = vi.fn( + () => + new Promise< void >( ( resolve ) => { + calls.push( 'site-1:start' ); + resolveFirst = resolve; + } ) + ); + const secondCapture = vi.fn().mockImplementation( async () => { + calls.push( 'site-2:start' ); + } ); + getMock.mockImplementation( ( id: string ) => ( { + details: { id, running: true }, + updateCachedThumbnail: id === 'site-1' ? firstCapture : secondCapture, + } ) ); + + const { captureSiteThumbnail } = await import( 'src/lib/capture-site-thumbnail' ); + const first = captureSiteThumbnail( 'site-1' ); + const second = captureSiteThumbnail( 'site-2' ); + + await Promise.resolve(); + expect( calls ).toEqual( [ 'site-1:start' ] ); + + resolveFirst(); + await Promise.all( [ first, second ] ); + + expect( calls ).toEqual( [ 'site-1:start', 'site-2:start' ] ); + } ); + + it( 'skips stopped sites without emitting loading state', async () => { + getMock.mockReturnValue( { details: { id: 'site-1', running: false } } ); + + const { captureSiteThumbnail } = await import( 'src/lib/capture-site-thumbnail' ); + await captureSiteThumbnail( 'site-1' ); + + expect( mockSendIpcEventToRenderer ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/apps/studio/src/lib/tests/site-maintenance-tasks.test.ts b/apps/studio/src/lib/tests/site-maintenance-tasks.test.ts new file mode 100644 index 0000000000..4f8270d3ad --- /dev/null +++ b/apps/studio/src/lib/tests/site-maintenance-tasks.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; + +const enqueueMock = vi.fn(); + +vi.mock( 'src/lib/background-tasks', () => ( { + backgroundTasks: { + enqueue: enqueueMock, + }, +} ) ); + +describe( 'scheduleLocalAdminChecksRefresh', () => { + beforeEach( () => { + vi.clearAllMocks(); + enqueueMock.mockResolvedValue( undefined ); + } ); + + it( 'queues a per-site WP-CLI admin checks refresh task', async () => { + const executeWpCliCommand = vi.fn().mockResolvedValue( { + stdout: 'Success: Executed a total of 1 cron event.', + stderr: '', + exitCode: 0, + } ); + const server = { + details: { id: 'site-1', running: true }, + executeWpCliCommand, + }; + + const { scheduleLocalAdminChecksRefresh } = await import( 'src/lib/site-maintenance-tasks' ); + await scheduleLocalAdminChecksRefresh( server as never, 'test' ); + + expect( enqueueMock ).toHaveBeenCalledWith( { + name: 'refresh-local-admin-checks', + scope: 'site:site-1', + trigger: 'test', + delayMs: 1000, + requiresRunning: expect.any( Function ), + run: expect.any( Function ), + } ); + + const task = enqueueMock.mock.calls[ 0 ][ 0 ]; + expect( task.requiresRunning() ).toBe( true ); + await task.run( { signal: new AbortController().signal } ); + + expect( executeWpCliCommand ).toHaveBeenCalledWith( [ + 'eval', + expect.stringContaining( 'wp_version_check();' ), + ] ); + expect( executeWpCliCommand.mock.calls[ 0 ][ 0 ][ 1 ] ).toContain( 'wp_update_plugins();' ); + expect( executeWpCliCommand.mock.calls[ 0 ][ 0 ][ 1 ] ).toContain( 'wp_update_themes();' ); + expect( executeWpCliCommand.mock.calls[ 0 ][ 0 ][ 1 ] ).toContain( + 'wp_check_browser_version();' + ); + expect( executeWpCliCommand.mock.calls[ 0 ][ 0 ][ 1 ] ).toContain( 'wp_check_php_version();' ); + expect( executeWpCliCommand.mock.calls[ 0 ][ 0 ][ 1 ] ).toContain( + 'wp_get_available_translations();' + ); + } ); + + it( 'fails when WP-CLI cannot refresh local admin checks', async () => { + const executeWpCliCommand = vi.fn().mockResolvedValue( { + stdout: '', + stderr: 'Error: No events ran.', + exitCode: 1, + } ); + const server = { + details: { id: 'site-1', running: true }, + executeWpCliCommand, + }; + + const { scheduleLocalAdminChecksRefresh } = await import( 'src/lib/site-maintenance-tasks' ); + await scheduleLocalAdminChecksRefresh( server as never ); + + const task = enqueueMock.mock.calls[ 0 ][ 0 ]; + await expect( task.run( { signal: new AbortController().signal } ) ).rejects.toThrow( + 'Error: No events ran.' + ); + } ); +} ); 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 f380078e3d..cb533beb1c 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -10,6 +10,7 @@ import { import { sequential } from '@studio/common/lib/sequential'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { captureSiteThumbnail } from 'src/lib/capture-site-thumbnail'; +import { scheduleLocalAdminChecksRefresh } from 'src/lib/site-maintenance-tasks'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { SiteServer } from 'src/site-server'; @@ -74,14 +75,17 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > if ( eventType === SITE_EVENTS.CREATED ) { const existingServer = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); + let server: SiteServer; if ( ! existingServer ) { - SiteServer.register( siteDetailsToServerDetails( site, running ) ); + server = SiteServer.register( siteDetailsToServerDetails( site, running ) ); } else { existingServer.details = siteDetailsToServerDetails( site, running, existingServer.details ); + server = existingServer; } void sendIpcEventToRenderer( 'site-event', event ); if ( running ) { void captureSiteThumbnail( siteId ); + void scheduleLocalAdminChecksRefresh( server, 'site-created' ).catch( () => undefined ); } return; } @@ -104,6 +108,7 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > if ( wasNotRunning && running ) { void captureSiteThumbnail( siteId ); + void scheduleLocalAdminChecksRefresh( server, 'site-started' ).catch( () => undefined ); await server.getThemeDetails(); await server.getSiteIcon(); } 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..c83772bd54 --- /dev/null +++ b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import EventEmitter from 'node:events'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; +import { vi } from 'vitest'; + +let emitter: EventEmitter; +const mockExecuteCliCommand = vi.fn(); +const mockSendIpcEventToRenderer = vi.fn().mockResolvedValue( undefined ); +const mockCaptureSiteThumbnail = vi.fn().mockResolvedValue( undefined ); +const mockScheduleLocalAdminChecksRefresh = vi.fn().mockResolvedValue( undefined ); +const mockRegister = vi.fn(); +const mockGet = vi.fn(); +const mockGetByPath = vi.fn(); + +vi.mock( 'src/modules/cli/lib/execute-command', () => ( { + executeCliCommand: mockExecuteCliCommand, +} ) ); +vi.mock( 'src/ipc-utils', () => ( { + sendIpcEventToRenderer: mockSendIpcEventToRenderer, +} ) ); +vi.mock( 'src/lib/capture-site-thumbnail', () => ( { + captureSiteThumbnail: mockCaptureSiteThumbnail, +} ) ); +vi.mock( 'src/lib/site-maintenance-tasks', () => ( { + scheduleLocalAdminChecksRefresh: mockScheduleLocalAdminChecksRefresh, +} ) ); +vi.mock( 'src/site-server', () => ( { + SiteServer: { + get: mockGet, + getByPath: mockGetByPath, + register: mockRegister, + unregister: vi.fn(), + }, +} ) ); + +function siteEventData( event: SITE_EVENTS, running: boolean ) { + return { + action: 'keyValuePair', + key: 'site-event', + value: JSON.stringify( { + event, + siteId: 'site-1', + running, + site: { + id: 'site-1', + name: 'Site 1', + path: '/site-1', + port: 9999, + url: 'http://localhost:9999', + phpVersion: '8.4', + }, + } ), + }; +} + +async function startSubscriber() { + emitter = new EventEmitter(); + mockExecuteCliCommand.mockReturnValue( [ emitter, { kill: vi.fn() } ] ); + const subscriber = await import( 'src/modules/cli/lib/cli-events-subscriber' ); + const started = subscriber.startCliEventsSubscriber(); + emitter.emit( 'started' ); + await started; + return subscriber; +} + +describe( 'cli-events-subscriber background work', () => { + beforeEach( () => { + vi.resetModules(); + vi.clearAllMocks(); + } ); + + it( 'schedules thumbnail capture and local admin refresh for created running sites', async () => { + const server = { details: { id: 'site-1', running: true } }; + mockRegister.mockReturnValue( server ); + + await startSubscriber(); + emitter.emit( 'data', { data: siteEventData( SITE_EVENTS.CREATED, true ) } ); + await Promise.resolve(); + + expect( mockCaptureSiteThumbnail ).toHaveBeenCalledWith( 'site-1' ); + expect( mockScheduleLocalAdminChecksRefresh ).toHaveBeenCalledWith( server, 'site-created' ); + } ); + + it( 'schedules thumbnail capture and local admin refresh when a site starts', async () => { + const server = { + details: { id: 'site-1', running: false }, + server: {}, + getThemeDetails: vi.fn().mockResolvedValue( undefined ), + getSiteIcon: vi.fn().mockResolvedValue( undefined ), + }; + mockGet.mockReturnValue( server ); + + await startSubscriber(); + emitter.emit( 'data', { data: siteEventData( SITE_EVENTS.UPDATED, true ) } ); + await Promise.resolve(); + await Promise.resolve(); + + expect( mockCaptureSiteThumbnail ).toHaveBeenCalledWith( 'site-1' ); + expect( mockScheduleLocalAdminChecksRefresh ).toHaveBeenCalledWith( server, 'site-started' ); + expect( server.getThemeDetails ).toHaveBeenCalled(); + expect( server.getSiteIcon ).toHaveBeenCalled(); + } ); +} ); diff --git a/tools/common/lib/mu-plugins.ts b/tools/common/lib/mu-plugins.ts index 9c337d3909..d6da3c1d94 100644 --- a/tools/common/lib/mu-plugins.ts +++ b/tools/common/lib/mu-plugins.ts @@ -302,6 +302,135 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { } ); } + muPlugins.push( { + filename: '0-local-admin-performance.php', + content: ` array(), + 'body' => wp_json_encode( $body ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + ); + } + + // Studio sites are local development sites. Avoid blocking passive wp-admin + // navigation on opportunistic WordPress.org update checks. A background + // Studio task refreshes the real update and health transients after startup. + add_action( 'admin_init', function() { + if ( ! studio_should_defer_local_admin_checks() ) { + return; + } + + remove_action( 'admin_init', '_maybe_update_core' ); + remove_action( 'admin_init', '_maybe_update_plugins' ); + remove_action( 'admin_init', '_maybe_update_themes' ); + studio_schedule_local_admin_checks_refresh(); + }, 0 ); + + add_filter( 'pre_http_request', function( $preempt, $parsed_args, $url ) { + if ( ! studio_is_dashboard_request() ) { + return $preempt; + } + + if ( str_contains( $url, 'api.wordpress.org/core/browse-happy/1.1/' ) ) { + studio_schedule_local_admin_checks_refresh(); + return studio_local_admin_check_response( array( + 'platform' => '', + 'name' => '', + 'version' => '', + 'current_version' => '', + 'upgrade' => false, + 'insecure' => false, + 'update_url' => '', + 'img_src' => '', + 'img_src_ssl' => '', + ) ); + } + + if ( str_contains( $url, 'api.wordpress.org/core/serve-happy/1.0/' ) ) { + studio_schedule_local_admin_checks_refresh(); + return studio_local_admin_check_response( array( + 'recommended_version' => PHP_VERSION, + 'minimum_version' => '7.4', + 'is_supported' => true, + 'is_secure' => true, + 'is_acceptable' => true, + 'is_lower_than_future_minimum' => false, + ) ); + } + + return $preempt; + }, 10, 3 ); + `, + } ); + // HTTP request timeout muPlugins.push( { filename: '0-http-request-timeout.php', diff --git a/tools/common/lib/tests/mu-plugins.test.ts b/tools/common/lib/tests/mu-plugins.test.ts index 39b203940c..4ff371b1b9 100644 --- a/tools/common/lib/tests/mu-plugins.test.ts +++ b/tools/common/lib/tests/mu-plugins.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { cleanupLegacyMuPlugins, + getMuPlugins, STUDIO_LOADER_MU_PLUGIN_FILENAME, writeStudioMuPluginsForNativePhpRuntime, } from '@studio/common/lib/mu-plugins'; @@ -115,3 +116,31 @@ describe( 'writeStudioMuPluginsForNativePhpRuntime', () => { expect( generatedPlugins ).not.toContain( '0-disable-auto-updates.php' ); } ); } ); + +describe( 'local admin performance mu-plugin', () => { + it( 'defers opportunistic checks on passive admin pages and exposes the refresh hook', async () => { + const [ muPluginsDir ] = await getMuPlugins( {} ); + const pluginContent = await readFile( join( muPluginsDir, '0-local-admin-performance.php' ), 'utf8' ); + + expect( pluginContent ).toContain( + "const STUDIO_REFRESH_LOCAL_ADMIN_CHECKS_HOOK = 'studio_refresh_local_admin_checks';" + ); + expect( pluginContent ).toContain( "'/wp-admin/' === $path" ); + expect( pluginContent ).toContain( "'/wp-admin/index.php' === $path" ); + expect( pluginContent ).toContain( 'function studio_should_defer_local_admin_checks()' ); + expect( pluginContent ).toContain( "'/wp-admin/update-core.php'" ); + expect( pluginContent ).toContain( "'/wp-admin/plugins.php'" ); + expect( pluginContent ).toContain( "'/wp-admin/plugin-install.php'" ); + expect( pluginContent ).toContain( "'/wp-admin/themes.php'" ); + expect( pluginContent ).toContain( "'/wp-admin/theme-install.php'" ); + expect( pluginContent ).toContain( "remove_action( 'admin_init', '_maybe_update_core' );" ); + expect( pluginContent ).toContain( "remove_action( 'admin_init', '_maybe_update_plugins' );" ); + expect( pluginContent ).toContain( "remove_action( 'admin_init', '_maybe_update_themes' );" ); + expect( pluginContent ).toContain( 'wp_version_check();' ); + expect( pluginContent ).toContain( 'wp_update_plugins();' ); + expect( pluginContent ).toContain( 'wp_update_themes();' ); + expect( pluginContent ).toContain( 'wp_check_browser_version();' ); + expect( pluginContent ).toContain( 'wp_check_php_version();' ); + expect( pluginContent ).toContain( 'wp_get_available_translations();' ); + } ); +} ); From f38397b3c6066ecaf8b9b9a8c0864c07fea54419 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 7 May 2026 12:12:26 -0400 Subject: [PATCH 2/2] Fix local admin test formatting --- tools/common/lib/tests/mu-plugins.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/common/lib/tests/mu-plugins.test.ts b/tools/common/lib/tests/mu-plugins.test.ts index 4ff371b1b9..d923d20b29 100644 --- a/tools/common/lib/tests/mu-plugins.test.ts +++ b/tools/common/lib/tests/mu-plugins.test.ts @@ -120,7 +120,10 @@ describe( 'writeStudioMuPluginsForNativePhpRuntime', () => { describe( 'local admin performance mu-plugin', () => { it( 'defers opportunistic checks on passive admin pages and exposes the refresh hook', async () => { const [ muPluginsDir ] = await getMuPlugins( {} ); - const pluginContent = await readFile( join( muPluginsDir, '0-local-admin-performance.php' ), 'utf8' ); + const pluginContent = await readFile( + join( muPluginsDir, '0-local-admin-performance.php' ), + 'utf8' + ); expect( pluginContent ).toContain( "const STUDIO_REFRESH_LOCAL_ADMIN_CHECKS_HOOK = 'studio_refresh_local_admin_checks';"