diff --git a/cli/commands/site/set-xdebug.ts b/cli/commands/site/set-xdebug.ts new file mode 100644 index 000000000..c3c694519 --- /dev/null +++ b/cli/commands/site/set-xdebug.ts @@ -0,0 +1,118 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + isXdebugBetaEnabled, + lockAppdata, + readAppdata, + saveAppdata, + SiteData, + unlockAppdata, + updateSiteLatestCliPid, +} from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { + isServerRunning, + startWordPressServer, + stopWordPressServer, +} from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const logger = new Logger< LoggerAction >(); + +export async function runCommand( sitePath: string, enableXdebug: boolean ): Promise< void > { + try { + if ( ! ( await isXdebugBetaEnabled() ) ) { + throw new LoggerError( + __( 'Xdebug support is a beta feature. Enable it in Studio settings first.' ) + ); + } + + let site: SiteData; + + try { + await lockAppdata(); + const appdata = await readAppdata(); + + const foundSite = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); + if ( ! foundSite ) { + throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); + } + + site = foundSite; + + if ( enableXdebug ) { + const otherXdebugSite = appdata.sites.find( ( s ) => s.enableXdebug && s.id !== site.id ); + if ( otherXdebugSite ) { + throw new LoggerError( + sprintf( + /* translators: %s: site name */ + __( + 'Only one site can have Xdebug enabled at a time. Disable Xdebug on "%s" first.' + ), + otherXdebugSite.name + ) + ); + } + } + + if ( site.enableXdebug === enableXdebug ) { + if ( enableXdebug ) { + throw new LoggerError( __( 'Xdebug is already enabled for this site.' ) ); + } else { + throw new LoggerError( __( 'Xdebug is already disabled for this site.' ) ); + } + } + + site.enableXdebug = enableXdebug; + await saveAppdata( appdata ); + } finally { + await unlockAppdata(); + } + + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); + await connect(); + logger.reportSuccess( __( 'Process daemon started' ) ); + + const runningProcess = await isServerRunning( site.id ); + + if ( runningProcess ) { + logger.reportStart( LoggerAction.START_SITE, __( 'Restarting site...' ) ); + await stopWordPressServer( site.id ); + const processDesc = await startWordPressServer( site, logger ); + if ( processDesc.pid ) { + await updateSiteLatestCliPid( site.id, processDesc.pid ); + } + logger.reportSuccess( __( 'Site restarted' ) ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set-xdebug ', + describe: __( 'Enable or disable Xdebug for a local site' ), + builder: ( yargs ) => { + return yargs.positional( 'enable', { + type: 'boolean', + description: __( 'Enable Xdebug' ), + demandOption: true, + } ); + }, + handler: async ( argv ) => { + try { + await runCommand( argv.path, argv.enable ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to configure Xdebug' ), error ); + logger.reportError( loggerError ); + } + } + }, + } ); +}; diff --git a/cli/commands/site/tests/set-xdebug.test.ts b/cli/commands/site/tests/set-xdebug.test.ts new file mode 100644 index 000000000..be3c6198d --- /dev/null +++ b/cli/commands/site/tests/set-xdebug.test.ts @@ -0,0 +1,296 @@ +import { arePathsEqual } from 'common/lib/fs-utils'; +import { + isXdebugBetaEnabled, + lockAppdata, + readAppdata, + saveAppdata, + SiteData, + unlockAppdata, + updateSiteLatestCliPid, +} from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { + isServerRunning, + startWordPressServer, + stopWordPressServer, +} from 'cli/lib/wordpress-server-manager'; +import { Logger } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + isXdebugBetaEnabled: jest.fn(), + lockAppdata: jest.fn(), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + unlockAppdata: jest.fn(), + updateSiteLatestCliPid: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'common/lib/fs-utils' ); + +describe( 'CLI: studio site set-xdebug', () => { + const mockSiteFolder = '/test/site/path'; + + const createMockSiteData = (): SiteData => ( { + id: 'test-site-id', + name: 'Test Site', + path: mockSiteFolder, + port: 8881, + adminUsername: 'admin', + adminPassword: 'password123', + running: false, + phpVersion: '8.0', + url: `http://localhost:8881`, + enableXdebug: false, + } ); + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockSiteData: SiteData; + + beforeEach( () => { + jest.clearAllMocks(); + + mockSiteData = createMockSiteData(); + + ( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( true ); + ( connect as jest.Mock ).mockResolvedValue( undefined ); + ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( updateSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); + ( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + describe( 'Error Cases', () => { + it( 'should throw when beta feature is not enabled', async () => { + ( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( false ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'Xdebug support is a beta feature. Enable it in Studio settings first.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when site not found', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'The specified folder is not added to Studio.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when another site already has xdebug enabled', async () => { + const otherSite = { + ...createMockSiteData(), + id: 'other-site-id', + name: 'Other Site', + path: '/other/site/path', + enableXdebug: true, + }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData, otherSite ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'Only one site can have Xdebug enabled at a time. Disable Xdebug on "Other Site" first.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw if xdebug is already enabled', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...mockSiteData, enableXdebug: true } ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'Xdebug is already enabled for this site.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw if xdebug is already disabled', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...mockSiteData, enableXdebug: false } ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, false ) ).rejects.toThrow( + 'Xdebug is already disabled for this site.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when appdata save fails', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when PM2 connection fails', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when stopping running site fails', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../set-xdebug' ); + + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should enable xdebug on a stopped site', async () => { + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, true ); + + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + expect( saveAppdata ).toHaveBeenCalled(); + + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( true ); + + expect( unlockAppdata ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( updateSiteLatestCliPid ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disable xdebug on a stopped site', async () => { + const siteWithXdebug = { ...mockSiteData, enableXdebug: true }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithXdebug ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, false ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( false ); + + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( updateSiteLatestCliPid ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should enable xdebug and restart a running site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, true ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( true ); + + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalledWith( + expect.any( Object ), + expect.any( Logger ) + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disable xdebug and restart a running site', async () => { + const siteWithXdebug = { ...mockSiteData, enableXdebug: true }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithXdebug ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, false ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( false ); + + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should update latestCliPid after restarting a running site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, true ); + + expect( updateSiteLatestCliPid ).toHaveBeenCalledWith( + mockSiteData.id, + mockProcessDescription.pid + ); + } ); + + it( 'should not update latestCliPid if process has no pid', async () => { + const processWithoutPid = { ...mockProcessDescription, pid: undefined }; + ( isServerRunning as jest.Mock ).mockResolvedValue( processWithoutPid ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( processWithoutPid ); + + const { runCommand } = await import( '../set-xdebug' ); + + await runCommand( mockSiteFolder, true ); + + expect( updateSiteLatestCliPid ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index c07291c43..8d3c2ed56 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -18,6 +18,7 @@ import { registerCommand as registerSiteSetDomainCommand } from 'cli/commands/si import { registerCommand as registerSiteSetHttpsCommand } from 'cli/commands/site/set-https'; import { registerCommand as registerSiteSetPhpVersionCommand } from 'cli/commands/site/set-php-version'; import { registerCommand as registerSiteSetWpVersionCommand } from 'cli/commands/site/set-wp-version'; +import { registerCommand as registerSiteSetXdebugCommand } from 'cli/commands/site/set-xdebug'; import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; @@ -104,6 +105,7 @@ async function main() { registerSiteSetDomainCommand( sitesYargs ); registerSiteSetPhpVersionCommand( sitesYargs ); registerSiteSetWpVersionCommand( sitesYargs ); + registerSiteSetXdebugCommand( sitesYargs ); sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .command( { diff --git a/cli/lib/appdata.ts b/cli/lib/appdata.ts index 4fbf6db6d..da910d98b 100644 --- a/cli/lib/appdata.ts +++ b/cli/lib/appdata.ts @@ -28,12 +28,14 @@ const siteSchema = z autoStart: z.boolean().optional(), url: z.string().optional(), latestCliPid: z.number().optional(), + enableXdebug: z.boolean().optional(), } ) .passthrough(); const betaFeaturesSchema = z .object( { multiWorkerSupport: z.boolean().optional(), + xdebugSupport: z.boolean().optional(), } ) .passthrough(); @@ -148,6 +150,15 @@ export async function unlockAppdata(): Promise< void > { await unlockFileAsync( LOCKFILE_PATH ); } +export async function isXdebugBetaEnabled(): Promise< boolean > { + try { + const appdata = await readAppdata(); + return appdata.betaFeatures?.xdebugSupport ?? false; + } catch { + return false; + } +} + export async function getAuthToken(): Promise< ValidatedAuthToken > { try { const { authToken } = await readAppdata(); diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index 123d887b6..d08df182a 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -13,6 +13,7 @@ const serverConfig = z.object( { siteLanguage: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), enableMultiWorker: z.boolean().optional(), + enableXdebug: z.boolean().optional(), blueprint: z .object( { contents: z.any(), // Blueprint type is complex, allow any for now diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 3443e68d0..c84c8e81b 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -11,7 +11,7 @@ import { PLAYGROUND_CLI_MAX_TIMEOUT, } from 'common/constants'; import { z } from 'zod'; -import { SiteData, readAppdata } from 'cli/lib/appdata'; +import { isXdebugBetaEnabled, SiteData, readAppdata } from 'cli/lib/appdata'; import { isProcessRunning, startProcess, @@ -112,6 +112,10 @@ export async function startWordPressServer( }; } + if ( ( await isXdebugBetaEnabled() ) && site.enableXdebug ) { + serverConfig.enableXdebug = true; + } + const env = { ELECTRON_RUN_AS_NODE: '1', STUDIO_WORDPRESS_SERVER_CONFIG: JSON.stringify( serverConfig ), @@ -358,6 +362,10 @@ export async function runBlueprint( serverConfig.wpVersion = options.wpVersion; } + if ( ( await isXdebugBetaEnabled() ) && site.enableXdebug ) { + serverConfig.enableXdebug = true; + } + const env = { ELECTRON_RUN_AS_NODE: '1', STUDIO_WORDPRESS_SERVER_CONFIG: JSON.stringify( serverConfig ), diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index 5fd143ffe..ae41d2e47 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -213,6 +213,11 @@ async function getBaseRunCLIArgs( args.experimentalMultiWorker = workerCount; } + if ( config.enableXdebug ) { + logToConsole( 'Enabling Xdebug support' ); + args.xdebug = true; + } + return args; }