From fd24a5f795e517b354f2d472a6a918b537f7edd1 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 26 Nov 2025 17:39:00 +0000 Subject: [PATCH 01/19] Implement studio site stop CLI command with graceful shutdown --- cli/commands/site/stop.ts | 60 ++++++++ cli/commands/site/tests/stop.test.ts | 193 ++++++++++++++++++++++++++ cli/index.ts | 2 + cli/lib/appdata.ts | 17 +++ cli/lib/types/wordpress-server-ipc.ts | 6 + cli/lib/wordpress-server-manager.ts | 71 +++++++++- cli/wordpress-server-child.ts | 28 +++- common/logger-actions.ts | 1 + 8 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 cli/commands/site/stop.ts create mode 100644 cli/commands/site/tests/stop.test.ts diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts new file mode 100644 index 0000000000..b67d53df4c --- /dev/null +++ b/cli/commands/site/stop.ts @@ -0,0 +1,60 @@ +import { __ } from '@wordpress/i18n'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( siteFolder: string ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => arePathsEqual( s.path, siteFolder ) ); + + if ( ! site ) { + throw new LoggerError( __( 'Could not find Studio site.' ) ); + } + + await connect(); + + const runningProcess = await isServerRunning( site.id ); + if ( ! runningProcess ) { + logger.reportSuccess( __( 'WordPress site is not running' ) ); + return; + } + + logger.reportStart( LoggerAction.STOP_SITE, __( 'Stopping WordPress site...' ) ); + try { + await stopWordPressServer( site.id ); + await clearSiteLatestCliPid( site.id ); + logger.reportSuccess( __( 'WordPress site stopped' ) ); + } catch ( error ) { + throw new LoggerError( __( 'Failed to stop WordPress server' ), error ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to stop site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'stop', + describe: __( 'Stop local site' ), + builder: ( yargs ) => { + return yargs; + }, + handler: async ( argv ) => { + await runCommand( argv.path ); + }, + } ); +}; diff --git a/cli/commands/site/tests/stop.test.ts b/cli/commands/site/tests/stop.test.ts new file mode 100644 index 0000000000..0c46892224 --- /dev/null +++ b/cli/commands/site/tests/stop.test.ts @@ -0,0 +1,193 @@ +import { SiteData, clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), + readAppdata: jest.fn(), + clearSiteLatestCliPid: jest.fn(), + getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ), +} ) ); +jest.mock( 'common/lib/fs-utils', () => ( { + ...jest.requireActual( 'common/lib/fs-utils' ), + arePathsEqual: jest.fn( ( a: string, b: string ) => a === b ), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'cli/logger' ); + +describe( 'Site Stop Command', () => { + const mockSiteFolder = '/test/site/path'; + const mockSiteData: SiteData = { + id: 'test-site-id', + name: 'Test Site', + path: mockSiteFolder, + port: 8881, + adminUsername: 'admin', + adminPassword: 'password123', + running: true, + phpVersion: '8.0', + url: `http://localhost:8881`, + }; + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( connect as jest.Mock ).mockResolvedValue( undefined ); + ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); + ( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + describe( 'Error Handling', () => { + it( 'should handle site not found error', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle PM2 connection failure', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle WordPress server stop failure', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopSite', + 'Stopping WordPress site...' + ); + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should report that site is not running if server is not running', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site is not running' ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( clearSiteLatestCliPid ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should stop a running site', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( connect ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopSite', + 'Stopping WordPress site...' + ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( clearSiteLatestCliPid ).toHaveBeenCalledWith( mockSiteData.id ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site stopped' ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Cleanup', () => { + it( 'should disconnect from PM2 even on error', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Appdata error' ) ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disconnect from PM2 on success', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disconnect from PM2 even if server was not running', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index 7ea9589914..4f520eb4d0 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/ import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; 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'; import { readAppdata } from 'cli/lib/appdata'; import { loadTranslations } from 'cli/lib/i18n'; import { bumpAggregatedUniqueStat } from 'cli/lib/stats'; @@ -86,6 +87,7 @@ async function main() { registerSiteCreateCommand( sitesYargs ); registerSiteListCommand( sitesYargs ); registerSiteStartCommand( sitesYargs ); + registerSiteStopCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); } diff --git a/cli/lib/appdata.ts b/cli/lib/appdata.ts index e48caa0795..f4a31337f6 100644 --- a/cli/lib/appdata.ts +++ b/cli/lib/appdata.ts @@ -261,3 +261,20 @@ export async function updateSiteLatestCliPid( siteId: string, pid: number ): Pro await unlockAppdata(); } } + +export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { + try { + await lockAppdata(); + const userData = await readAppdata(); + const site = userData.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + delete site.latestCliPid; + await saveAppdata( userData ); + } finally { + await unlockAppdata(); + } +} diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index b4a674b6a8..708d6fea94 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -35,9 +35,15 @@ const managerMessageRunBlueprint = managerMessageBase.extend( { } ), } ); +const managerMessageStopServer = managerMessageBase.extend( { + topic: z.literal( 'stop-server' ), + data: z.object( {} ), +} ); + export const managerMessageSchema = z.discriminatedUnion( 'topic', [ managerMessageStartServer, managerMessageRunBlueprint, + managerMessageStopServer, ] ); export type ManagerMessage = z.infer< typeof managerMessageSchema >; diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 57349e43a9..c92419d0ee 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -213,15 +213,84 @@ async function sendMessage( bus.on( 'process:msg', responseHandler ); - sendMessageToProcess( pmId, { ...message, messageId: messageId } ).catch( reject ); + sendMessageToProcess( pmId, { ...message, messageId } as ManagerMessage ).catch( reject ); } ); } +const GRACEFUL_STOP_TIMEOUT = 5000; + export async function stopWordPressServer( siteId: string ): Promise< void > { const processName = getProcessName( siteId ); + const runningProcess = await isProcessRunning( processName ); + + if ( runningProcess ) { + try { + await sendStopMessage( runningProcess.pmId ); + } catch { + // Graceful shutdown failed, PM2 delete will handle it + } + } + return stopProcess( processName ); } +/** + * Send stop message to the child process with a timeout + */ +async function sendStopMessage( pmId: number ): Promise< void > { + const bus = await getPm2Bus(); + + return new Promise( ( resolve, reject ) => { + const messageId = nextMessageId++; + + const timeout = setTimeout( () => { + cleanup(); + reject( new Error( 'Graceful stop timeout' ) ); + }, GRACEFUL_STOP_TIMEOUT ); + + const cleanup = () => { + clearTimeout( timeout ); + bus.off( 'process:msg', responseHandler ); + }; + + const responseHandler = ( packet: unknown ) => { + const validationResult = childMessagePm2Schema.safeParse( packet ); + if ( ! validationResult.success ) { + return; + } + + const validPacket = validationResult.data; + + if ( validPacket.process.pm_id !== pmId ) { + return; + } + + if ( validPacket.raw.topic === 'error' && validPacket.raw.originalMessageId === messageId ) { + cleanup(); + reject( new Error( validPacket.raw.errorMessage ) ); + } else if ( + validPacket.raw.topic === 'result' && + validPacket.raw.originalMessageId === messageId + ) { + cleanup(); + resolve(); + } + }; + + bus.on( 'process:msg', responseHandler ); + + const stopMessage = { + topic: 'stop-server' as const, + data: {}, + messageId, + }; + sendMessageToProcess( pmId, stopMessage ).catch( ( error ) => { + cleanup(); + reject( error ); + } ); + } ); +} + /** * Run a blueprint on a site without starting a server * 1. Start the PM2 process diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index 93352d6e96..91c64b94f0 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -139,7 +139,7 @@ async function startServer( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'server', config ); - const server = await runCLI( args ); + server = await runCLI( args ); if ( config.adminPassword ) { await setAdminPassword( server, config.adminPassword ); @@ -151,6 +151,29 @@ async function startServer( config: ServerConfig ): Promise< void > { } } +const STOP_SERVER_TIMEOUT = 5000; + +async function stopServer(): Promise< void > { + if ( ! server ) { + logToConsole( 'No server running, nothing to stop' ); + return; + } + + const serverToDispose = server; + server = null; + + try { + const disposalTimeout = new Promise< void >( ( _, reject ) => + setTimeout( () => reject( new Error( 'Server disposal timeout' ) ), STOP_SERVER_TIMEOUT ) + ); + + await Promise.race( [ serverToDispose[ Symbol.asyncDispose ](), disposalTimeout ] ); + logToConsole( 'Server stopped gracefully' ); + } catch ( error ) { + errorToConsole( 'Error during server disposal:', error ); + } +} + async function runBlueprint( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'run-blueprint', config ); @@ -197,6 +220,9 @@ async function ipcMessageHandler( packet: unknown ) { case 'run-blueprint': result = await runBlueprint( validMessage.data.config ); break; + case 'stop-server': + result = await stopServer(); + break; default: throw new Error( `Unknown message.` ); } diff --git a/common/logger-actions.ts b/common/logger-actions.ts index 45c9a47870..bc9987ce0b 100644 --- a/common/logger-actions.ts +++ b/common/logger-actions.ts @@ -24,6 +24,7 @@ export enum SiteCommandLoggerAction { GENERATE_CERT = 'generateCert', ADD_DOMAIN_TO_HOSTS = 'addDomainToHosts', START_SITE = 'startSite', + STOP_SITE = 'stopSite', VALIDATE = 'validate', CREATE_DIRECTORY = 'createDirectory', INSTALL_SQLITE = 'installSqlite', From 25bd8ceebb485f73a011e9fd565df77c89218129 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 27 Nov 2025 10:05:55 +0100 Subject: [PATCH 02/19] Fix types and remove sendStopMessage --- cli/lib/types/wordpress-server-ipc.ts | 21 ++++---- cli/lib/wordpress-server-manager.ts | 71 +++------------------------ 2 files changed, 20 insertions(+), 72 deletions(-) diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index 708d6fea94..d0e3a46b33 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -1,10 +1,6 @@ import { z } from 'zod'; // Zod schemas for validating IPC messages from wordpress-server-manager -const managerMessageBase = z.object( { - messageId: z.number(), -} ); - const serverConfig = z.object( { siteId: z.string(), sitePath: z.string(), @@ -21,30 +17,37 @@ const serverConfig = z.object( { export type ServerConfig = z.infer< typeof serverConfig >; -const managerMessageStartServer = managerMessageBase.extend( { +const managerMessageStartServer = z.object( { topic: z.literal( 'start-server' ), data: z.object( { config: serverConfig, } ), } ); -const managerMessageRunBlueprint = managerMessageBase.extend( { +const managerMessageRunBlueprint = z.object( { topic: z.literal( 'run-blueprint' ), data: z.object( { config: serverConfig, } ), } ); -const managerMessageStopServer = managerMessageBase.extend( { +const managerMessageStopServer = z.object( { topic: z.literal( 'stop-server' ), - data: z.object( {} ), } ); -export const managerMessageSchema = z.discriminatedUnion( 'topic', [ +const _managerMessagePayloadSchema = z.discriminatedUnion( 'topic', [ managerMessageStartServer, managerMessageRunBlueprint, managerMessageStopServer, ] ); +export type ManagerMessagePayload = z.infer< typeof _managerMessagePayloadSchema >; + +const managerMessageBase = z.object( { messageId: z.number() } ); +export const managerMessageSchema = z.discriminatedUnion( 'topic', [ + managerMessageBase.merge( managerMessageStartServer ), + managerMessageBase.merge( managerMessageRunBlueprint ), + managerMessageBase.merge( managerMessageStopServer ), +] ); export type ManagerMessage = z.infer< typeof managerMessageSchema >; // Zod schemas for validating IPC messages from wordpress-server-child diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index c92419d0ee..94d14bb12d 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -23,6 +23,7 @@ import { ServerConfig, childMessagePm2Schema, ManagerMessage, + ManagerMessagePayload, } from 'cli/lib/types/wordpress-server-ipc'; function getProcessName( siteId: string ): string { @@ -137,7 +138,8 @@ const messageActivityTrackers = new Map< async function sendMessage( pmId: number, - message: Omit< ManagerMessage, 'messageId' > + message: ManagerMessagePayload, + maxTotalElapsedTime = PLAYGROUND_CLI_INACTIVITY_TIMEOUT ): Promise< unknown > { const bus = await getPm2Bus(); @@ -162,12 +164,12 @@ async function sendMessage( if ( timeSinceLastActivity > PLAYGROUND_CLI_INACTIVITY_TIMEOUT || - totalElapsedTime > PLAYGROUND_CLI_MAX_TIMEOUT + totalElapsedTime > maxTotalElapsedTime ) { cleanup(); const timeoutReason = - totalElapsedTime > PLAYGROUND_CLI_MAX_TIMEOUT - ? `Maximum timeout of ${ PLAYGROUND_CLI_MAX_TIMEOUT / 1000 }s exceeded` + totalElapsedTime > maxTotalElapsedTime + ? `Maximum timeout of ${ maxTotalElapsedTime / 1000 }s exceeded` : `No activity for ${ PLAYGROUND_CLI_INACTIVITY_TIMEOUT / 1000 }s`; reject( new Error( `Timeout waiting for response to message ${ messageId }: ${ timeoutReason }` ) @@ -213,7 +215,7 @@ async function sendMessage( bus.on( 'process:msg', responseHandler ); - sendMessageToProcess( pmId, { ...message, messageId } as ManagerMessage ).catch( reject ); + sendMessageToProcess( pmId, { ...message, messageId } ).catch( reject ); } ); } @@ -225,7 +227,7 @@ export async function stopWordPressServer( siteId: string ): Promise< void > { if ( runningProcess ) { try { - await sendStopMessage( runningProcess.pmId ); + await sendMessage( runningProcess.pmId, { topic: 'stop-server' }, GRACEFUL_STOP_TIMEOUT ); } catch { // Graceful shutdown failed, PM2 delete will handle it } @@ -234,63 +236,6 @@ export async function stopWordPressServer( siteId: string ): Promise< void > { return stopProcess( processName ); } -/** - * Send stop message to the child process with a timeout - */ -async function sendStopMessage( pmId: number ): Promise< void > { - const bus = await getPm2Bus(); - - return new Promise( ( resolve, reject ) => { - const messageId = nextMessageId++; - - const timeout = setTimeout( () => { - cleanup(); - reject( new Error( 'Graceful stop timeout' ) ); - }, GRACEFUL_STOP_TIMEOUT ); - - const cleanup = () => { - clearTimeout( timeout ); - bus.off( 'process:msg', responseHandler ); - }; - - const responseHandler = ( packet: unknown ) => { - const validationResult = childMessagePm2Schema.safeParse( packet ); - if ( ! validationResult.success ) { - return; - } - - const validPacket = validationResult.data; - - if ( validPacket.process.pm_id !== pmId ) { - return; - } - - if ( validPacket.raw.topic === 'error' && validPacket.raw.originalMessageId === messageId ) { - cleanup(); - reject( new Error( validPacket.raw.errorMessage ) ); - } else if ( - validPacket.raw.topic === 'result' && - validPacket.raw.originalMessageId === messageId - ) { - cleanup(); - resolve(); - } - }; - - bus.on( 'process:msg', responseHandler ); - - const stopMessage = { - topic: 'stop-server' as const, - data: {}, - messageId, - }; - sendMessageToProcess( pmId, stopMessage ).catch( ( error ) => { - cleanup(); - reject( error ); - } ); - } ); -} - /** * Run a blueprint on a site without starting a server * 1. Start the PM2 process From e2bc0826d8e7665490b9e767566fa1cc0785d62c Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 27 Nov 2025 17:22:10 +0000 Subject: [PATCH 03/19] stop proxy if needed --- cli/commands/site/stop.ts | 2 + cli/commands/site/tests/stop.test.ts | 29 ++++++ cli/lib/pm2-manager.ts | 4 + cli/lib/site-utils.ts | 33 ++++++- cli/lib/tests/site-utils.test.ts | 142 +++++++++++++++++++++++++++ common/logger-actions.ts | 1 + 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 cli/lib/tests/site-utils.test.ts diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts index b67d53df4c..39ddf6adac 100644 --- a/cli/commands/site/stop.ts +++ b/cli/commands/site/stop.ts @@ -3,6 +3,7 @@ import { arePathsEqual } from 'common/lib/fs-utils'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; import { clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -31,6 +32,7 @@ export async function runCommand( siteFolder: string ): Promise< void > { await stopWordPressServer( site.id ); await clearSiteLatestCliPid( site.id ); logger.reportSuccess( __( 'WordPress site stopped' ) ); + await stopProxyIfNoSitesNeedIt( site.id, logger ); } catch ( error ) { throw new LoggerError( __( 'Failed to stop WordPress server' ), error ); } diff --git a/cli/commands/site/tests/stop.test.ts b/cli/commands/site/tests/stop.test.ts index 0c46892224..37d11bdc35 100644 --- a/cli/commands/site/tests/stop.test.ts +++ b/cli/commands/site/tests/stop.test.ts @@ -1,5 +1,6 @@ import { SiteData, clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; @@ -15,6 +16,7 @@ jest.mock( 'common/lib/fs-utils', () => ( { arePathsEqual: jest.fn( ( a: string, b: string ) => a === b ), } ) ); jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/site-utils' ); jest.mock( 'cli/lib/wordpress-server-manager' ); jest.mock( 'cli/logger' ); @@ -60,6 +62,7 @@ describe( 'Site Stop Command', () => { ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); ( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); + ( stopProxyIfNoSitesNeedIt as jest.Mock ).mockResolvedValue( undefined ); } ); afterEach( () => { @@ -152,6 +155,32 @@ describe( 'Site Stop Command', () => { expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site stopped' ); expect( disconnect ).toHaveBeenCalled(); } ); + + it( 'should call stopProxyIfNoSitesNeedIt after stopping a site', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalledWith( mockSiteData.id, mockLogger ); + } ); + + it( 'should not call stopProxyIfNoSitesNeedIt if site is not running', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../stop' ); + await runCommand( mockSiteFolder ); + + expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled(); + } ); } ); describe( 'Cleanup', () => { diff --git a/cli/lib/pm2-manager.ts b/cli/lib/pm2-manager.ts index 1384a3f35f..7205577326 100644 --- a/cli/lib/pm2-manager.ts +++ b/cli/lib/pm2-manager.ts @@ -165,6 +165,10 @@ export async function isProxyProcessRunning(): Promise< ProcessDescription | und return isProcessRunning( PROXY_PROCESS_NAME ); } +export async function stopProxyProcess(): Promise< void > { + return stopProcess( PROXY_PROCESS_NAME ); +} + export async function isProcessRunning( processName: string ): Promise< ProcessDescription | undefined > { diff --git a/cli/lib/site-utils.ts b/cli/lib/site-utils.ts index c219d04134..3e641e5d0f 100644 --- a/cli/lib/site-utils.ts +++ b/cli/lib/site-utils.ts @@ -1,10 +1,11 @@ import { __ } from '@wordpress/i18n'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { getSiteUrl, SiteData } from 'cli/lib/appdata'; +import { getSiteUrl, readAppdata, SiteData } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; import { addDomainToHosts } from 'cli/lib/hosts-file'; -import { isProxyProcessRunning, startProxyProcess } from 'cli/lib/pm2-manager'; +import { isProxyProcessRunning, startProxyProcess, stopProxyProcess } from 'cli/lib/pm2-manager'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; /** @@ -74,3 +75,31 @@ export async function setupCustomDomain( throw new LoggerError( __( 'Failed to add domain to hosts file' ), error ); } } + +/** + * Stops the HTTP proxy server if no remaining running sites need it. + * A site needs the proxy if it has a custom domain configured. + * + * @param stoppedSiteId - The ID of the site that was just stopped (to exclude from the check) + */ +export async function stopProxyIfNoSitesNeedIt( + stoppedSiteId: string, + logger: Logger< LoggerAction > +): Promise< void > { + const proxyProcess = await isProxyProcessRunning(); + if ( ! proxyProcess ) { + return; + } + + const appdata = await readAppdata(); + + for ( const site of appdata.sites ) { + if ( site.id !== stoppedSiteId && site.customDomain && ( await isServerRunning( site.id ) ) ) { + return; + } + } + + logger.reportStart( LoggerAction.STOP_PROXY, __( 'Stopping HTTP proxy server...' ) ); + await stopProxyProcess(); + logger.reportSuccess( __( 'HTTP proxy server stopped' ) ); +} diff --git a/cli/lib/tests/site-utils.test.ts b/cli/lib/tests/site-utils.test.ts new file mode 100644 index 0000000000..aff4e13c49 --- /dev/null +++ b/cli/lib/tests/site-utils.test.ts @@ -0,0 +1,142 @@ +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { SiteData, readAppdata } from 'cli/lib/appdata'; +import { isProxyProcessRunning, stopProxyProcess } from 'cli/lib/pm2-manager'; +import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; +import { Logger } from 'cli/logger'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), + readAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); + +describe( 'stopProxyIfNoSitesNeedIt', () => { + const mockProcessDescription = { + name: 'studio-proxy', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: Logger< LoggerAction >; + + const createSiteData = ( overrides: Partial< SiteData > = {} ): SiteData => ( { + id: 'site-1', + name: 'Test Site', + path: '/test/site', + port: 8881, + phpVersion: '8.0', + ...overrides, + } ); + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + } as unknown as Logger< LoggerAction >; + + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( undefined ); + ( stopProxyProcess as jest.Mock ).mockResolvedValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( readAppdata as jest.Mock ).mockResolvedValue( { sites: [], snapshots: [] } ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + it( 'should do nothing if proxy is not running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( undefined ); + + await stopProxyIfNoSitesNeedIt( 'site-1', mockLogger ); + + expect( readAppdata ).not.toHaveBeenCalled(); + expect( stopProxyProcess ).not.toHaveBeenCalled(); + } ); + + it( 'should stop proxy if no other sites exist', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ createSiteData( { id: 'stopped-site' } ) ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'stopProxy', + 'Stopping HTTP proxy server...' + ); + expect( stopProxyProcess ).toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'HTTP proxy server stopped' ); + } ); + + it( 'should stop proxy if other sites exist but none have custom domains', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'other-site-1' } ), + createSiteData( { id: 'other-site-2' } ), + ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); + + it( 'should stop proxy if other sites have custom domains but are not running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'other-site', customDomain: 'other.local' } ), + ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).toHaveBeenCalledWith( 'other-site' ); + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); + + it( 'should not stop proxy if another site with custom domain is running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ + createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), + createSiteData( { id: 'running-site', customDomain: 'running.local' } ), + ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).toHaveBeenCalledWith( 'running-site' ); + expect( stopProxyProcess ).not.toHaveBeenCalled(); + } ); + + it( 'should not check if the stopped site is running', async () => { + ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ) ], + snapshots: [], + } ); + + await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); + + expect( isServerRunning ).not.toHaveBeenCalledWith( 'stopped-site' ); + expect( stopProxyProcess ).toHaveBeenCalled(); + } ); +} ); diff --git a/common/logger-actions.ts b/common/logger-actions.ts index bc9987ce0b..9dcc3ff413 100644 --- a/common/logger-actions.ts +++ b/common/logger-actions.ts @@ -21,6 +21,7 @@ export enum SiteCommandLoggerAction { START_DAEMON = 'startDaemon', LOAD_SITES = 'loadSites', START_PROXY = 'startProxy', + STOP_PROXY = 'stopProxy', GENERATE_CERT = 'generateCert', ADD_DOMAIN_TO_HOSTS = 'addDomainToHosts', START_SITE = 'startSite', From fc01870c632977a80a2a2f4720193b61c5cb3e8a Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 27 Nov 2025 18:28:24 +0000 Subject: [PATCH 04/19] use getSiteByFolder --- cli/commands/site/stop.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts index 39ddf6adac..2f96cb3537 100644 --- a/cli/commands/site/stop.ts +++ b/cli/commands/site/stop.ts @@ -1,7 +1,6 @@ import { __ } from '@wordpress/i18n'; -import { arePathsEqual } from 'common/lib/fs-utils'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; +import { clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; @@ -12,12 +11,7 @@ export async function runCommand( siteFolder: string ): Promise< void > { const logger = new Logger< LoggerAction >(); try { - const appdata = await readAppdata(); - const site = appdata.sites.find( ( s ) => arePathsEqual( s.path, siteFolder ) ); - - if ( ! site ) { - throw new LoggerError( __( 'Could not find Studio site.' ) ); - } + const site = await getSiteByFolder( siteFolder ); await connect(); From 57a05ba9b36191f099d87f4e25b0f5cafe750dff Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 08:23:53 +0100 Subject: [PATCH 05/19] Fix issues from my previous commits --- cli/lib/wordpress-server-manager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 94d14bb12d..c04a1e45b9 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -22,7 +22,6 @@ import { ProcessDescription } from 'cli/lib/types/pm2'; import { ServerConfig, childMessagePm2Schema, - ManagerMessage, ManagerMessagePayload, } from 'cli/lib/types/wordpress-server-ipc'; @@ -139,7 +138,7 @@ const messageActivityTrackers = new Map< async function sendMessage( pmId: number, message: ManagerMessagePayload, - maxTotalElapsedTime = PLAYGROUND_CLI_INACTIVITY_TIMEOUT + maxTotalElapsedTime = PLAYGROUND_CLI_MAX_TIMEOUT ): Promise< unknown > { const bus = await getPm2Bus(); From 1a81ea86a53e80340da7c1f1b227838894445ce8 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 08:26:24 +0100 Subject: [PATCH 06/19] Clean up test file --- cli/commands/site/tests/stop.test.ts | 55 ++++++---------------------- 1 file changed, 12 insertions(+), 43 deletions(-) diff --git a/cli/commands/site/tests/stop.test.ts b/cli/commands/site/tests/stop.test.ts index 37d11bdc35..7b2975602e 100644 --- a/cli/commands/site/tests/stop.test.ts +++ b/cli/commands/site/tests/stop.test.ts @@ -1,4 +1,4 @@ -import { SiteData, clearSiteLatestCliPid, readAppdata } from 'cli/lib/appdata'; +import { SiteData, clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; @@ -7,14 +7,10 @@ import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), - readAppdata: jest.fn(), + getSiteByFolder: jest.fn(), clearSiteLatestCliPid: jest.fn(), getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ), } ) ); -jest.mock( 'common/lib/fs-utils', () => ( { - ...jest.requireActual( 'common/lib/fs-utils' ), - arePathsEqual: jest.fn( ( a: string, b: string ) => a === b ), -} ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/site-utils' ); jest.mock( 'cli/lib/wordpress-server-manager' ); @@ -71,10 +67,7 @@ describe( 'Site Stop Command', () => { describe( 'Error Handling', () => { it( 'should handle site not found error', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); const { runCommand } = await import( '../stop' ); await runCommand( mockSiteFolder ); @@ -84,10 +77,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should handle PM2 connection failure', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../stop' ); @@ -98,10 +88,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should handle WordPress server stop failure', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); @@ -119,10 +106,7 @@ describe( 'Site Stop Command', () => { describe( 'Success Cases', () => { it( 'should report that site is not running if server is not running', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../stop' ); @@ -135,10 +119,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should stop a running site', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../stop' ); @@ -157,10 +138,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should call stopProxyIfNoSitesNeedIt after stopping a site', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../stop' ); @@ -170,10 +148,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should not call stopProxyIfNoSitesNeedIt if site is not running', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../stop' ); @@ -185,7 +160,7 @@ describe( 'Site Stop Command', () => { describe( 'Cleanup', () => { it( 'should disconnect from PM2 even on error', async () => { - ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Appdata error' ) ); + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site error' ) ); const { runCommand } = await import( '../stop' ); await runCommand( mockSiteFolder ); @@ -194,10 +169,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should disconnect from PM2 on success', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../stop' ); @@ -207,10 +179,7 @@ describe( 'Site Stop Command', () => { } ); it( 'should disconnect from PM2 even if server was not running', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../stop' ); From a4b15b7786b4b35d4767f16138a3df819b301010 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 28 Nov 2025 10:31:15 +0000 Subject: [PATCH 07/19] fix lint and unit tests --- cli/commands/site/list.ts | 1 - cli/commands/site/stop.ts | 2 +- common/lib/cache-function-ttl.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/commands/site/list.ts b/cli/commands/site/list.ts index d1423c56e1..8b707c94de 100644 --- a/cli/commands/site/list.ts +++ b/cli/commands/site/list.ts @@ -1,4 +1,3 @@ -import os from 'node:os'; import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts index 2f96cb3537..2c1e2059b3 100644 --- a/cli/commands/site/stop.ts +++ b/cli/commands/site/stop.ts @@ -11,7 +11,7 @@ export async function runCommand( siteFolder: string ): Promise< void > { const logger = new Logger< LoggerAction >(); try { - const site = await getSiteByFolder( siteFolder ); + const site = await getSiteByFolder( siteFolder, false ); await connect(); diff --git a/common/lib/cache-function-ttl.ts b/common/lib/cache-function-ttl.ts index 5d4d191b2e..a227345d0f 100644 --- a/common/lib/cache-function-ttl.ts +++ b/common/lib/cache-function-ttl.ts @@ -31,7 +31,7 @@ export function cacheFunctionTTL< Args extends unknown[], Return >( } function pruneCache(): void { - for ( const [ fn, cachedResults ] of cache ) { + for ( const [ _, cachedResults ] of cache ) { for ( const cachedResult of cachedResults ) { if ( Date.now() - cachedResult.timestamp >= cachedResult.ttl ) { cachedResults.delete( cachedResult ); From cb20e2d39a40a776a8aad2418bc229b059a67d09 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 12:45:22 +0100 Subject: [PATCH 08/19] CLI: Implement `studio site set-domain` and `studio site set-https` --- cli/commands/site/set-domain.ts | 106 ++++ cli/commands/site/set-https.ts | 80 +++ cli/commands/site/tests/set-domain.test.ts | 213 ++++++++ cli/commands/site/tests/set-https.test.ts | 239 +++++++++ cli/index.ts | 4 + cli/lib/hosts-file.ts | 41 -- cli/lib/proxy-server.ts | 2 +- cli/lib/wordpress-server-manager.ts | 7 +- cli/polyfills/browser-globals.js | 1 + cli/wordpress-server-child.ts | 3 + common/logger-actions.ts | 1 + package-lock.json | 549 ++++++++++++++++++++- package.json | 2 +- 13 files changed, 1182 insertions(+), 66 deletions(-) create mode 100644 cli/commands/site/set-domain.ts create mode 100644 cli/commands/site/set-https.ts create mode 100644 cli/commands/site/tests/set-domain.test.ts create mode 100644 cli/commands/site/tests/set-https.test.ts diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts new file mode 100644 index 0000000000..4d182336c6 --- /dev/null +++ b/cli/commands/site/set-domain.ts @@ -0,0 +1,106 @@ +import { __, _n } from '@wordpress/i18n'; +import { getDomainNameValidationError } from 'common/lib/domains'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} from 'cli/lib/appdata'; +import { removeDomainFromHosts } from 'cli/lib/hosts-file'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { setupCustomDomain } from 'cli/lib/site-utils'; +import { + isServerRunning, + startWordPressServer, + stopWordPressServer, +} from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export async function runCommand( sitePath: string, domainName: string ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); + let site = await getSiteByFolder( sitePath, false ); + logger.reportSuccess( __( 'Site loaded' ) ); + + try { + await lockAppdata(); + const appdata = await readAppdata(); + + const existingDomains = appdata.sites + .map( ( site ) => site.customDomain ) + .filter( ( domain ): domain is string => Boolean( domain ) ); + const domainError = getDomainNameValidationError( true, domainName, existingDomains ); + + if ( domainError ) { + throw new LoggerError( domainError ); + } + + 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; + const oldDomainName = site.customDomain; + site.customDomain = domainName; + await saveAppdata( appdata ); + + if ( oldDomainName ) { + logger.reportStart( + LoggerAction.REMOVE_DOMAIN_FROM_HOSTS, + __( 'Removing domain from hosts file...' ) + ); + await removeDomainFromHosts( oldDomainName ); + logger.reportSuccess( __( 'Domain removed from hosts file' ) ); + } + } 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 ) { + await stopWordPressServer( site.id ); + await setupCustomDomain( site, logger ); + logger.reportStart( LoggerAction.START_SITE, __( 'Restarting site...' ) ); + await startWordPressServer( site ); + logger.reportSuccess( __( 'Site restarted' ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set-domain ', + describe: __( 'Set domain for a local site' ), + builder: ( yargs ) => { + return yargs.positional( 'domain', { + type: 'string', + description: __( 'Domain name' ), + required: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.path, argv.domain ?? '' ); + }, + } ); +}; diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts new file mode 100644 index 0000000000..d134a14c4b --- /dev/null +++ b/cli/commands/site/set-https.ts @@ -0,0 +1,80 @@ +import { __, _n } from '@wordpress/i18n'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} 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'; + +export async function runCommand( sitePath: string, enableHttps: boolean ): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); + const site = await getSiteByFolder( sitePath, false ); + logger.reportSuccess( __( 'Site loaded' ) ); + + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); + await connect(); + logger.reportSuccess( __( 'Process daemon started' ) ); + + try { + await lockAppdata(); + const appdata = await readAppdata(); + const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); + if ( ! site ) { + throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); + } + site.enableHttps = enableHttps; + await saveAppdata( appdata ); + } finally { + await unlockAppdata(); + } + + const runningProcess = await isServerRunning( site.id ); + + if ( runningProcess ) { + logger.reportStart( LoggerAction.START_SITE, __( 'Restarting site...' ) ); + await stopWordPressServer( site.id ); + await startWordPressServer( site ); + logger.reportSuccess( __( 'Site restarted' ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set-https ', + describe: __( 'Set HTTPS for a local site' ), + builder: ( yargs ) => { + return yargs.positional( 'enable', { + type: 'boolean', + description: __( 'Enable HTTPS' ), + required: true, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.path, !! argv.enable ); + }, + } ); +}; diff --git a/cli/commands/site/tests/set-domain.test.ts b/cli/commands/site/tests/set-domain.test.ts new file mode 100644 index 0000000000..0aa968584b --- /dev/null +++ b/cli/commands/site/tests/set-domain.test.ts @@ -0,0 +1,213 @@ +import { arePathsEqual } from 'common/lib/fs-utils'; +import { + SiteData, + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} 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'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getSiteByFolder: jest.fn(), + lockAppdata: jest.fn(), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + unlockAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'cli/logger' ); +jest.mock( 'common/lib/fs-utils' ); + +describe( 'Site Set-Domain Command', () => { + const mockSiteFolder = '/test/site/path'; + const mockDomainName = 'example.local'; + const mockSiteData: 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`, + }; + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( 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 ); + ( 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 Handling', () => { + it( 'should handle PM2 connection failure', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'startDaemon', + 'Starting process daemon...' + ); + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata lock failure', async () => { + ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata read failure', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle domain validation error', async () => { + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, 'invalid domain' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( saveAppdata ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw error when domain already exists', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...mockSiteData, customDomain: 'example.local' } ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( saveAppdata ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle site not found in appdata', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata save failure', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle WordPress server stop failure', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should set domain on a stopped site', async () => { + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].customDomain ).toBe( mockDomainName ); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should set domain and restart a running site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].customDomain ).toBe( mockDomainName ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts new file mode 100644 index 0000000000..f325c168b9 --- /dev/null +++ b/cli/commands/site/tests/set-https.test.ts @@ -0,0 +1,239 @@ +import { arePathsEqual } from 'common/lib/fs-utils'; +import { + SiteData, + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} 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'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getSiteByFolder: jest.fn(), + lockAppdata: jest.fn(), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + unlockAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'cli/logger' ); +jest.mock( 'common/lib/fs-utils' ); + +describe( 'Site Set-HTTPS Command', () => { + const mockSiteFolder = '/test/site/path'; + const mockSiteData: 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`, + enableHttps: false, + }; + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( 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 ); + ( 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 Handling', () => { + it( 'should handle PM2 connection failure', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( + 'startDaemon', + 'Starting process daemon...' + ); + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata lock failure', async () => { + ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata read failure', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle site not found in appdata', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle WordPress server stop failure', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'startSite', 'Restarting site...' ); + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should enable HTTPS on a stopped site', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'loadSites', 'Loading site…' ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); + expect( connect ).toHaveBeenCalled(); + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disable HTTPS on a stopped site', async () => { + const siteWithHttps = { ...mockSiteData, enableHttps: true }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithHttps ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, false ); + + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( false ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should enable HTTPS and restart a running site', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ mockSiteData ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'startSite', 'Restarting site...' ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should disable HTTPS and restart a running site', async () => { + const siteWithHttps = { ...mockSiteData, enableHttps: true }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithHttps ], + snapshots: [], + } ); + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, false ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( false ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalled(); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index 4f520eb4d0..b314715109 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -13,6 +13,8 @@ import { registerCommand as registerListCommand } from 'cli/commands/preview/lis import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update'; import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create'; import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; +import { registerCommand as registerSiteSetDomainCommand } from 'cli/commands/site/set-domain'; +import { registerCommand as registerSiteSetHttpsCommand } from 'cli/commands/site/set-https'; 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'; @@ -88,6 +90,8 @@ async function main() { registerSiteListCommand( sitesYargs ); registerSiteStartCommand( sitesYargs ); registerSiteStopCommand( sitesYargs ); + registerSiteSetHttpsCommand( sitesYargs ); + registerSiteSetDomainCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); } diff --git a/cli/lib/hosts-file.ts b/cli/lib/hosts-file.ts index 7e5ac77718..5d8c402236 100644 --- a/cli/lib/hosts-file.ts +++ b/cli/lib/hosts-file.ts @@ -127,47 +127,6 @@ export const removeDomainFromHosts = async ( domain: string ): Promise< void > = } }; -export const updateDomainInHosts = async ( - oldDomain: string | undefined, - newDomain: string | undefined, - port: number -): Promise< void > => { - if ( oldDomain === newDomain ) { - return; - } - - if ( ! oldDomain && newDomain ) { - await addDomainToHosts( newDomain, port ); - return; - } - - if ( oldDomain && ! newDomain ) { - await removeDomainFromHosts( oldDomain ); - return; - } - - try { - const hostsContent = await readHostsFile(); - const encodedOldDomain = domainToASCII( oldDomain as string ); - const encodedNewDomain = domainToASCII( newDomain as string ); - const oldPattern = createHostsEntryPattern( encodedOldDomain ); - const newContent = updateStudioBlock( hostsContent, ( entries ) => { - const filtered = entries.filter( ( entry ) => ! entry.match( oldPattern ) ); - return [ ...filtered, `127.0.0.1 ${ encodedNewDomain } # Port ${ port }` ]; - } ); - - if ( newContent !== hostsContent ) { - await writeHostsFile( newContent ); - } - } catch ( error ) { - console.error( - `Error replacing domain ${ oldDomain } with ${ newDomain } in hosts file:`, - error - ); - throw error; - } -}; - /** * Helper function for manipulating the "block" of entries in the hosts file * pertaining to WordPress Studio. diff --git a/cli/lib/proxy-server.ts b/cli/lib/proxy-server.ts index 23a4692f1e..142495ac93 100644 --- a/cli/lib/proxy-server.ts +++ b/cli/lib/proxy-server.ts @@ -157,7 +157,7 @@ async function startHttpsProxy(): Promise< void > { SNICallback: async ( servername, cb ) => { try { const site = await getSiteByHost( servername ); - if ( ! site || ! site.customDomain ) { + if ( ! site || ! site.customDomain || ! site.enableHttps ) { console.error( `[Proxy] SNI: Invalid hostname: ${ servername }` ); cb( new Error( `Invalid hostname: ${ servername }` ) ); return; diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index c04a1e45b9..6be5237e23 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -109,7 +109,7 @@ async function waitForReadyMessage( pmId: number ): Promise< void > { return; } - if ( result.data.process.pm_id === pmId && result.data.raw.topic === 'ready' ) { + if ( result.data.raw.topic === 'ready' && result.data.process.pm_id === pmId ) { clearTimeout( timeout ); bus.off( 'process:msg', readyHandler ); resolve(); @@ -214,7 +214,10 @@ async function sendMessage( bus.on( 'process:msg', responseHandler ); - sendMessageToProcess( pmId, { ...message, messageId } ).catch( reject ); + sendMessageToProcess( pmId, { ...message, messageId } ).catch( ( error ) => { + cleanup(); + reject( error ); + } ); } ); } diff --git a/cli/polyfills/browser-globals.js b/cli/polyfills/browser-globals.js index 74e76eed13..a9149edb39 100644 --- a/cli/polyfills/browser-globals.js +++ b/cli/polyfills/browser-globals.js @@ -14,6 +14,7 @@ if ( typeof document === 'undefined' ) { global.document = { createElement: () => ( {} ), addEventListener: () => {}, + baseURI: 'file:///', }; } diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index 91c64b94f0..f5a6f29eda 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -139,6 +139,7 @@ async function startServer( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'server', config ); + logToConsole( `Starting server. Calling runCli with args`, args ); server = await runCLI( args ); if ( config.adminPassword ) { @@ -232,7 +233,9 @@ async function ipcMessageHandler( packet: unknown ) { topic: 'result', result, }; + console.log( 'Sending response', response ); process.send!( response ); + console.log( 'Sent response' ); } if ( process.send ) { diff --git a/common/logger-actions.ts b/common/logger-actions.ts index 9dcc3ff413..7d853b48e8 100644 --- a/common/logger-actions.ts +++ b/common/logger-actions.ts @@ -24,6 +24,7 @@ export enum SiteCommandLoggerAction { STOP_PROXY = 'stopProxy', GENERATE_CERT = 'generateCert', ADD_DOMAIN_TO_HOSTS = 'addDomainToHosts', + REMOVE_DOMAIN_FROM_HOSTS = 'removeDomainFromHosts', START_SITE = 'startSite', STOP_SITE = 'stopSite', VALIDATE = 'validate', diff --git a/package-lock.json b/package-lock.json index 37cf40c8b1..8f834f73fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,7 @@ "winreg": "1.2.4", "wpcom": "^7.1.0", "wpcom-xhr-request": "^1.3.0", - "yargs": "17.7.2", + "yargs": "^18.0.0", "yauzl": "^3.2.0", "zod": "^3.24.3" }, @@ -3784,6 +3784,21 @@ "node": ">=8" } }, + "node_modules/@electron/rebuild/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@electron/rebuild/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -3908,6 +3923,25 @@ "dev": true, "license": "ISC" }, + "node_modules/@electron/rebuild/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -7746,6 +7780,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@php-wasm/fs-journal/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/fs-journal/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -7755,6 +7803,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@php-wasm/fs-journal/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@php-wasm/fs-journal/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/logger": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.0.22.tgz", @@ -7805,6 +7883,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@php-wasm/node/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/node/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -7813,6 +7905,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@php-wasm/node/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@php-wasm/node/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/progress": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.0.22.tgz", @@ -7939,6 +8061,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@php-wasm/web/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/web/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -7948,6 +8084,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@php-wasm/web/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@php-wasm/web/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/xdebug-bridge": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@php-wasm/xdebug-bridge/-/xdebug-bridge-3.0.22.tgz", @@ -7976,6 +8142,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@php-wasm/xdebug-bridge/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -7985,6 +8165,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -11510,6 +11720,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@wp-playground/blueprints/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/blueprints/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -11530,6 +11754,36 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz", "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==" }, + "node_modules/@wp-playground/blueprints/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wp-playground/blueprints/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/cli": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@wp-playground/cli/-/cli-3.0.22.tgz", @@ -11595,6 +11849,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@wp-playground/cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/cli/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -11616,6 +11884,36 @@ "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", "license": "(MIT AND Zlib)" }, + "node_modules/@wp-playground/cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wp-playground/cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/common": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-3.0.22.tgz", @@ -11676,6 +11974,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@wp-playground/storage/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/storage/node_modules/diff3": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", @@ -11700,6 +12012,36 @@ "node": ">=6" } }, + "node_modules/@wp-playground/storage/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wp-playground/storage/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/wordpress": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/@wp-playground/wordpress/-/wordpress-3.0.22.tgz", @@ -11725,6 +12067,20 @@ "fs-ext": "2.1.1" } }, + "node_modules/@wp-playground/wordpress/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wp-playground/wordpress/node_modules/ini": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", @@ -11734,6 +12090,36 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@wp-playground/wordpress/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wp-playground/wordpress/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -13452,27 +13838,69 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/clone": { @@ -18988,6 +19416,53 @@ } } }, + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/jest-config": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.5.tgz", @@ -28808,20 +29283,20 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { @@ -28832,6 +29307,38 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yauzl": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", diff --git a/package.json b/package.json index a96034a64f..247fe2609e 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "winreg": "1.2.4", "wpcom": "^7.1.0", "wpcom-xhr-request": "^1.3.0", - "yargs": "17.7.2", + "yargs": "^18.0.0", "yauzl": "^3.2.0", "zod": "^3.24.3" }, From 1cdde21ac0bc4a82138e704361296c426271e414 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 12:49:08 +0100 Subject: [PATCH 09/19] Harden `sendMessage` cleanup logic --- cli/lib/wordpress-server-manager.ts | 31 +++++++++++------------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 6be5237e23..4d3a93e1f6 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -141,21 +141,13 @@ async function sendMessage( maxTotalElapsedTime = PLAYGROUND_CLI_MAX_TIMEOUT ): Promise< unknown > { const bus = await getPm2Bus(); + const messageId = nextMessageId++; + let responseHandler: ( packet: unknown ) => void; return new Promise( ( resolve, reject ) => { - const messageId = nextMessageId++; const startTime = Date.now(); let lastActivityTimestamp = Date.now(); - const cleanup = () => { - bus.off( 'process:msg', responseHandler ); - const tracker = messageActivityTrackers.get( messageId ); - if ( tracker ) { - clearInterval( tracker.activityCheckIntervalId ); - messageActivityTrackers.delete( messageId ); - } - }; - const activityCheckIntervalId = setInterval( () => { const now = Date.now(); const timeSinceLastActivity = now - lastActivityTimestamp; @@ -165,7 +157,6 @@ async function sendMessage( timeSinceLastActivity > PLAYGROUND_CLI_INACTIVITY_TIMEOUT || totalElapsedTime > maxTotalElapsedTime ) { - cleanup(); const timeoutReason = totalElapsedTime > maxTotalElapsedTime ? `Maximum timeout of ${ maxTotalElapsedTime / 1000 }s exceeded` @@ -180,10 +171,9 @@ async function sendMessage( activityCheckIntervalId, } ); - const responseHandler = ( packet: unknown ) => { + responseHandler = ( packet: unknown ) => { const validationResult = childMessagePm2Schema.safeParse( packet ); if ( ! validationResult.success ) { - cleanup(); reject( validationResult.error ); return; } @@ -201,23 +191,26 @@ async function sendMessage( if ( validPacket.raw.errorStack ) { error.stack = validPacket.raw.errorStack; } - cleanup(); reject( error ); } else if ( validPacket.raw.topic === 'result' && validPacket.raw.originalMessageId === messageId ) { - cleanup(); resolve( validPacket.raw.result ); } }; bus.on( 'process:msg', responseHandler ); - sendMessageToProcess( pmId, { ...message, messageId } ).catch( ( error ) => { - cleanup(); - reject( error ); - } ); + sendMessageToProcess( pmId, { ...message, messageId } ).catch( reject ); + } ).finally( () => { + bus.off( 'process:msg', responseHandler ); + + const tracker = messageActivityTrackers.get( messageId ); + if ( tracker ) { + clearInterval( tracker.activityCheckIntervalId ); + messageActivityTrackers.delete( messageId ); + } } ); } From 7c935bb116de59c19c95811c20c076b0e1054877 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 12:52:04 +0100 Subject: [PATCH 10/19] Cleanup --- cli/commands/site/tests/set-domain.test.ts | 4 ++++ cli/lib/wordpress-server-manager.ts | 2 +- cli/wordpress-server-child.ts | 3 --- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/commands/site/tests/set-domain.test.ts b/cli/commands/site/tests/set-domain.test.ts index 0aa968584b..6a15433fc3 100644 --- a/cli/commands/site/tests/set-domain.test.ts +++ b/cli/commands/site/tests/set-domain.test.ts @@ -7,6 +7,7 @@ import { saveAppdata, unlockAppdata, } from 'cli/lib/appdata'; +import { addDomainToHosts, removeDomainFromHosts } from 'cli/lib/hosts-file'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { isServerRunning, @@ -27,6 +28,7 @@ jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); jest.mock( 'cli/logger' ); jest.mock( 'common/lib/fs-utils' ); +jest.mock( 'cli/lib/hosts-file' ); describe( 'Site Set-Domain Command', () => { const mockSiteFolder = '/test/site/path'; @@ -81,6 +83,8 @@ describe( 'Site Set-Domain Command', () => { ( startWordPressServer as jest.Mock ).mockResolvedValue( mockProcessDescription ); ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); ( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b ); + ( addDomainToHosts as jest.Mock ).mockResolvedValue( undefined ); + ( removeDomainFromHosts as jest.Mock ).mockResolvedValue( undefined ); } ); afterEach( () => { diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 4d3a93e1f6..7195ab2946 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -109,7 +109,7 @@ async function waitForReadyMessage( pmId: number ): Promise< void > { return; } - if ( result.data.raw.topic === 'ready' && result.data.process.pm_id === pmId ) { + if ( result.data.process.pm_id === pmId && result.data.raw.topic === 'ready' ) { clearTimeout( timeout ); bus.off( 'process:msg', readyHandler ); resolve(); diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index f5a6f29eda..91c64b94f0 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -139,7 +139,6 @@ async function startServer( config: ServerConfig ): Promise< void > { try { const args = await getBaseRunCLIArgs( 'server', config ); - logToConsole( `Starting server. Calling runCli with args`, args ); server = await runCLI( args ); if ( config.adminPassword ) { @@ -233,9 +232,7 @@ async function ipcMessageHandler( packet: unknown ) { topic: 'result', result, }; - console.log( 'Sending response', response ); process.send!( response ); - console.log( 'Sent response' ); } if ( process.send ) { From 8bdb8c80a5721489ecd8ca2f6dc76a1a5339c154 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 12:59:01 +0100 Subject: [PATCH 11/19] Disallow `set-https` command on sites without custom domains --- cli/commands/site/set-https.ts | 4 ++++ cli/commands/site/tests/set-https.test.ts | 22 +++++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts index d134a14c4b..96e7031c5c 100644 --- a/cli/commands/site/set-https.ts +++ b/cli/commands/site/set-https.ts @@ -25,6 +25,10 @@ export async function runCommand( sitePath: string, enableHttps: boolean ): Prom const site = await getSiteByFolder( sitePath, false ); logger.reportSuccess( __( 'Site loaded' ) ); + if ( ! site.customDomain ) { + throw new LoggerError( __( 'Site does not have a custom domain.' ) ); + } + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); await connect(); logger.reportSuccess( __( 'Process daemon started' ) ); diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts index f325c168b9..c1841e8797 100644 --- a/cli/commands/site/tests/set-https.test.ts +++ b/cli/commands/site/tests/set-https.test.ts @@ -1,4 +1,5 @@ import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; import { SiteData, getSiteByFolder, @@ -41,6 +42,7 @@ describe( 'Site Set-HTTPS Command', () => { phpVersion: '8.0', url: `http://localhost:8881`, enableHttps: false, + customDomain: 'test.local', }; const mockProcessDescription = { @@ -88,16 +90,23 @@ describe( 'Site Set-HTTPS Command', () => { } ); describe( 'Error Handling', () => { + it( 'should handle missing custom domain', async () => { + const siteWithoutDomain = { ...mockSiteData, customDomain: undefined }; + ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithoutDomain ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + it( 'should handle PM2 connection failure', async () => { ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startDaemon', - 'Starting process daemon...' - ); expect( mockLogger.reportError ).toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); @@ -142,7 +151,6 @@ describe( 'Site Set-HTTPS Command', () => { const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'startSite', 'Restarting site...' ); expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -159,8 +167,6 @@ describe( 'Site Set-HTTPS Command', () => { const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'loadSites', 'Loading site…' ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); expect( connect ).toHaveBeenCalled(); expect( lockAppdata ).toHaveBeenCalled(); expect( readAppdata ).toHaveBeenCalled(); @@ -185,7 +191,6 @@ describe( 'Site Set-HTTPS Command', () => { const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, false ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( false ); @@ -208,7 +213,6 @@ describe( 'Site Set-HTTPS Command', () => { const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'startSite', 'Restarting site...' ); expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); From 97b6f2023b12c6ff4e003efc7036fb91602d1403 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 13:07:45 +0100 Subject: [PATCH 12/19] Finish early if HTTPS config already matches --- cli/commands/site/set-https.ts | 8 ++++++++ cli/commands/site/tests/set-https.test.ts | 22 +++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts index 96e7031c5c..0df2059b97 100644 --- a/cli/commands/site/set-https.ts +++ b/cli/commands/site/set-https.ts @@ -29,6 +29,14 @@ export async function runCommand( sitePath: string, enableHttps: boolean ): Prom throw new LoggerError( __( 'Site does not have a custom domain.' ) ); } + if ( site.enableHttps === enableHttps ) { + if ( enableHttps ) { + throw new LoggerError( __( 'HTTPS is already enabled for this site.' ) ); + } else { + throw new LoggerError( __( 'HTTPS is already disabled for this site.' ) ); + } + } + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); await connect(); logger.reportSuccess( __( 'Process daemon started' ) ); diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts index c1841e8797..a83c9de0b5 100644 --- a/cli/commands/site/tests/set-https.test.ts +++ b/cli/commands/site/tests/set-https.test.ts @@ -74,7 +74,7 @@ describe( 'Site Set-HTTPS Command', () => { ( disconnect as jest.Mock ).mockReturnValue( undefined ); ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], + sites: [ { ...mockSiteData } ], snapshots: [], } ); ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); @@ -101,6 +101,16 @@ describe( 'Site Set-HTTPS Command', () => { expect( disconnect ).toHaveBeenCalled(); } ); + it( 'should finish early if passing the same config value that the site already has', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + + const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, false ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + it( 'should handle PM2 connection failure', async () => { ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); @@ -158,10 +168,6 @@ describe( 'Site Set-HTTPS Command', () => { describe( 'Success Cases', () => { it( 'should enable HTTPS on a stopped site', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../set-https' ); @@ -186,6 +192,7 @@ describe( 'Site Set-HTTPS Command', () => { sites: [ siteWithHttps ], snapshots: [], } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithHttps ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../set-https' ); @@ -200,10 +207,6 @@ describe( 'Site Set-HTTPS Command', () => { } ); it( 'should enable HTTPS and restart a running site', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ mockSiteData ], - snapshots: [], - } ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../set-https' ); @@ -225,6 +228,7 @@ describe( 'Site Set-HTTPS Command', () => { sites: [ siteWithHttps ], snapshots: [], } ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithHttps ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../set-https' ); From b985d9b5d754de8324734fac78454c5522c08131 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 13:08:37 +0100 Subject: [PATCH 13/19] Simplify --- cli/commands/site/set-domain.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts index 4d182336c6..2010cbcf1d 100644 --- a/cli/commands/site/set-domain.ts +++ b/cli/commands/site/set-domain.ts @@ -25,7 +25,7 @@ export async function runCommand( sitePath: string, domainName: string ): Promis try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); - let site = await getSiteByFolder( sitePath, false ); + const site = await getSiteByFolder( sitePath, false ); logger.reportSuccess( __( 'Site loaded' ) ); try { @@ -41,12 +41,10 @@ export async function runCommand( sitePath: string, domainName: string ): Promis throw new LoggerError( domainError ); } - const foundSite = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); - if ( ! foundSite ) { + const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); + if ( ! site ) { throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); } - - site = foundSite; const oldDomainName = site.customDomain; site.customDomain = domainName; await saveAppdata( appdata ); From b9e007ec9466c648c6d0f321165c066f81eff2e9 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 15:20:32 +0100 Subject: [PATCH 14/19] Fix yargs option config --- cli/commands/site/set-domain.ts | 4 ++-- cli/commands/site/set-https.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts index 2010cbcf1d..43408050ce 100644 --- a/cli/commands/site/set-domain.ts +++ b/cli/commands/site/set-domain.ts @@ -94,11 +94,11 @@ export const registerCommand = ( yargs: StudioArgv ) => { return yargs.positional( 'domain', { type: 'string', description: __( 'Domain name' ), - required: true, + demandOption: true, } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.domain ?? '' ); + await runCommand( argv.path, argv.domain ); }, } ); }; diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts index 0df2059b97..8e42f68338 100644 --- a/cli/commands/site/set-https.ts +++ b/cli/commands/site/set-https.ts @@ -82,11 +82,11 @@ export const registerCommand = ( yargs: StudioArgv ) => { return yargs.positional( 'enable', { type: 'boolean', description: __( 'Enable HTTPS' ), - required: true, + demandOption: true, } ); }, handler: async ( argv ) => { - await runCommand( argv.path, !! argv.enable ); + await runCommand( argv.path, argv.enable ); }, } ); }; From c903ef346a83605415222fa06206826bea67d5b0 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Fri, 28 Nov 2025 15:22:42 +0100 Subject: [PATCH 15/19] CLI: Implement `studio site set-php-version` --- cli/commands/site/set-php-version.ts | 91 +++++++++ cli/commands/site/tests/set-https.test.ts | 1 - .../site/tests/set-php-version.test.ts | 193 ++++++++++++++++++ cli/index.ts | 5 +- 4 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 cli/commands/site/set-php-version.ts create mode 100644 cli/commands/site/tests/set-php-version.test.ts diff --git a/cli/commands/site/set-php-version.ts b/cli/commands/site/set-php-version.ts new file mode 100644 index 0000000000..6721743770 --- /dev/null +++ b/cli/commands/site/set-php-version.ts @@ -0,0 +1,91 @@ +import { SupportedPHPVersions } from '@php-wasm/universal'; +import { __, _n } from '@wordpress/i18n'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} 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 ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; + +export async function runCommand( + sitePath: string, + phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ] +): Promise< void > { + const logger = new Logger< LoggerAction >(); + + try { + logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); + const site = await getSiteByFolder( sitePath, false ); + logger.reportSuccess( __( 'Site loaded' ) ); + + if ( site.phpVersion === phpVersion ) { + throw new LoggerError( __( 'Site is already using the specified PHP version.' ) ); + } + + try { + await lockAppdata(); + const appdata = await readAppdata(); + const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); + if ( ! site ) { + throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); + } + site.phpVersion = phpVersion; + 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 ); + await startWordPressServer( site ); + logger.reportSuccess( __( 'Site restarted' ) ); + } + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set-php-version ', + describe: __( 'Set PHP version for a local site' ), + builder: ( yargs ) => { + return yargs.positional( 'php-version', { + type: 'string', + description: __( 'PHP version' ), + demandOption: true, + choices: ALLOWED_PHP_VERSIONS, + } ); + }, + handler: async ( argv ) => { + await runCommand( argv.path, argv.phpVersion ); + }, + } ); +}; diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts index a83c9de0b5..149e401f38 100644 --- a/cli/commands/site/tests/set-https.test.ts +++ b/cli/commands/site/tests/set-https.test.ts @@ -1,5 +1,4 @@ import { arePathsEqual } from 'common/lib/fs-utils'; -import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; import { SiteData, getSiteByFolder, diff --git a/cli/commands/site/tests/set-php-version.test.ts b/cli/commands/site/tests/set-php-version.test.ts new file mode 100644 index 0000000000..2c83f8269d --- /dev/null +++ b/cli/commands/site/tests/set-php-version.test.ts @@ -0,0 +1,193 @@ +import { arePathsEqual } from 'common/lib/fs-utils'; +import { + SiteData, + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} 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'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getSiteByFolder: jest.fn(), + lockAppdata: jest.fn(), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + unlockAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'cli/logger' ); +jest.mock( 'common/lib/fs-utils' ); + +describe( 'Site Set-PHP-Version Command', () => { + const mockSiteFolder = '/test/site/path'; + const mockSiteData: 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`, + enableHttps: false, + customDomain: 'test.local', + }; + + const mockProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let mockLogger: { + reportStart: jest.Mock; + reportSuccess: jest.Mock; + reportError: jest.Mock; + }; + + beforeEach( () => { + jest.clearAllMocks(); + + mockLogger = { + reportStart: jest.fn(), + reportSuccess: jest.fn(), + reportError: jest.fn(), + }; + + ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( 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 ); + ( 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 Handling', () => { + it( 'should finish early if passing the same PHP version that the site already has', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '8.0' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle PM2 connection failure', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( mockLogger.reportError ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata lock failure', async () => { + ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle appdata read failure', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle site not found in appdata', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should handle WordPress server stop failure', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should update PHP version on a stopped site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( connect ).toHaveBeenCalled(); + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); + expect( unlockAppdata ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should update PHP version and restart a running site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + + const { runCommand } = await import( '../set-php-version' ); + await runCommand( mockSiteFolder, '7.4' ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); + expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index b314715109..4d8a57d6ac 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/ import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; import { registerCommand as registerSiteSetDomainCommand } from 'cli/commands/site/set-domain'; import { registerCommand as registerSiteSetHttpsCommand } from 'cli/commands/site/set-https'; +import { registerCommand as registerSiteSetPhpVersionCommand } from 'cli/commands/site/set-php-version'; 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'; @@ -82,9 +83,6 @@ async function main() { if ( isSitesCliEnabled ) { studioArgv.command( 'site', __( 'Manage local sites (Beta)' ), ( sitesYargs ) => { - sitesYargs.option( 'path', { - hidden: true, - } ); registerSiteStatusCommand( sitesYargs ); registerSiteCreateCommand( sitesYargs ); registerSiteListCommand( sitesYargs ); @@ -92,6 +90,7 @@ async function main() { registerSiteStopCommand( sitesYargs ); registerSiteSetHttpsCommand( sitesYargs ); registerSiteSetDomainCommand( sitesYargs ); + registerSiteSetPhpVersionCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); } From e8028ab3f7b80fc3c287eee113cd162357fa2a28 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Dec 2025 08:54:12 +0100 Subject: [PATCH 16/19] Address review comments --- cli/commands/site/set-domain.ts | 47 ++++++++++------------ cli/commands/site/set-https.ts | 45 +++++++++++---------- cli/commands/site/tests/set-domain.test.ts | 11 +---- cli/commands/site/tests/set-https.test.ts | 21 +++------- 4 files changed, 51 insertions(+), 73 deletions(-) diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts index 43408050ce..883ad17527 100644 --- a/cli/commands/site/set-domain.ts +++ b/cli/commands/site/set-domain.ts @@ -2,13 +2,7 @@ import { __, _n } from '@wordpress/i18n'; import { getDomainNameValidationError } from 'common/lib/domains'; import { arePathsEqual } from 'common/lib/fs-utils'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { - getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; +import { lockAppdata, readAppdata, saveAppdata, SiteData, unlockAppdata } from 'cli/lib/appdata'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { setupCustomDomain } from 'cli/lib/site-utils'; @@ -24,43 +18,44 @@ export async function runCommand( sitePath: string, domainName: string ): Promis const logger = new Logger< LoggerAction >(); try { - logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); - const site = await getSiteByFolder( sitePath, false ); - logger.reportSuccess( __( 'Site loaded' ) ); + let site: SiteData; + let oldDomainName: string | undefined; try { await lockAppdata(); const appdata = await readAppdata(); - const existingDomains = appdata.sites + 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; + const existingDomainNames = appdata.sites .map( ( site ) => site.customDomain ) .filter( ( domain ): domain is string => Boolean( domain ) ); - const domainError = getDomainNameValidationError( true, domainName, existingDomains ); + const domainError = getDomainNameValidationError( true, domainName, existingDomainNames ); if ( domainError ) { throw new LoggerError( domainError ); } - const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); - if ( ! site ) { - throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); - } - const oldDomainName = site.customDomain; + oldDomainName = site.customDomain; site.customDomain = domainName; await saveAppdata( appdata ); - - if ( oldDomainName ) { - logger.reportStart( - LoggerAction.REMOVE_DOMAIN_FROM_HOSTS, - __( 'Removing domain from hosts file...' ) - ); - await removeDomainFromHosts( oldDomainName ); - logger.reportSuccess( __( 'Domain removed from hosts file' ) ); - } } finally { await unlockAppdata(); } + if ( oldDomainName ) { + logger.reportStart( + LoggerAction.REMOVE_DOMAIN_FROM_HOSTS, + __( 'Removing domain from hosts file...' ) + ); + await removeDomainFromHosts( oldDomainName ); + logger.reportSuccess( __( 'Domain removed from hosts file' ) ); + } + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); await connect(); logger.reportSuccess( __( 'Process daemon started' ) ); diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts index 8e42f68338..700d0c39d3 100644 --- a/cli/commands/site/set-https.ts +++ b/cli/commands/site/set-https.ts @@ -6,6 +6,7 @@ import { lockAppdata, readAppdata, saveAppdata, + SiteData, unlockAppdata, } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; @@ -21,39 +22,41 @@ export async function runCommand( sitePath: string, enableHttps: boolean ): Prom const logger = new Logger< LoggerAction >(); try { - logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); - const site = await getSiteByFolder( sitePath, false ); - logger.reportSuccess( __( 'Site loaded' ) ); - - if ( ! site.customDomain ) { - throw new LoggerError( __( 'Site does not have a custom domain.' ) ); - } - - if ( site.enableHttps === enableHttps ) { - if ( enableHttps ) { - throw new LoggerError( __( 'HTTPS is already enabled for this site.' ) ); - } else { - throw new LoggerError( __( 'HTTPS is already disabled for this site.' ) ); - } - } - - logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); - await connect(); - logger.reportSuccess( __( 'Process daemon started' ) ); + let site: SiteData; try { await lockAppdata(); const appdata = await readAppdata(); - const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); - if ( ! site ) { + + 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 ( ! site.customDomain ) { + throw new LoggerError( __( 'Site does not have a custom domain.' ) ); + } + + if ( site.enableHttps === enableHttps ) { + if ( enableHttps ) { + throw new LoggerError( __( 'HTTPS is already enabled for this site.' ) ); + } else { + throw new LoggerError( __( 'HTTPS is already disabled for this site.' ) ); + } + } + site.enableHttps = enableHttps; 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 ) { diff --git a/cli/commands/site/tests/set-domain.test.ts b/cli/commands/site/tests/set-domain.test.ts index 6a15433fc3..654138bcc7 100644 --- a/cli/commands/site/tests/set-domain.test.ts +++ b/cli/commands/site/tests/set-domain.test.ts @@ -1,12 +1,5 @@ import { arePathsEqual } from 'common/lib/fs-utils'; -import { - SiteData, - getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; +import { SiteData, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; import { addDomainToHosts, removeDomainFromHosts } from 'cli/lib/hosts-file'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { @@ -18,7 +11,6 @@ import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), - getSiteByFolder: jest.fn(), lockAppdata: jest.fn(), readAppdata: jest.fn(), saveAppdata: jest.fn(), @@ -69,7 +61,6 @@ describe( 'Site Set-Domain Command', () => { ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( connect as jest.Mock ).mockResolvedValue( undefined ); ( disconnect as jest.Mock ).mockReturnValue( undefined ); ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts index a83c9de0b5..b14293e553 100644 --- a/cli/commands/site/tests/set-https.test.ts +++ b/cli/commands/site/tests/set-https.test.ts @@ -1,13 +1,5 @@ import { arePathsEqual } from 'common/lib/fs-utils'; -import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { - SiteData, - getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; +import { SiteData, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { isServerRunning, @@ -18,7 +10,6 @@ import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), - getSiteByFolder: jest.fn(), lockAppdata: jest.fn(), readAppdata: jest.fn(), saveAppdata: jest.fn(), @@ -69,7 +60,6 @@ describe( 'Site Set-HTTPS Command', () => { ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); ( connect as jest.Mock ).mockResolvedValue( undefined ); ( disconnect as jest.Mock ).mockReturnValue( undefined ); ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); @@ -92,7 +82,10 @@ describe( 'Site Set-HTTPS Command', () => { describe( 'Error Handling', () => { it( 'should handle missing custom domain', async () => { const siteWithoutDomain = { ...mockSiteData, customDomain: undefined }; - ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithoutDomain ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithoutDomain ], + snapshots: [], + } ); const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, true ); @@ -102,8 +95,6 @@ describe( 'Site Set-HTTPS Command', () => { } ); it( 'should finish early if passing the same config value that the site already has', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - const { runCommand } = await import( '../set-https' ); await runCommand( mockSiteFolder, false ); @@ -192,7 +183,6 @@ describe( 'Site Set-HTTPS Command', () => { sites: [ siteWithHttps ], snapshots: [], } ); - ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithHttps ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../set-https' ); @@ -228,7 +218,6 @@ describe( 'Site Set-HTTPS Command', () => { sites: [ siteWithHttps ], snapshots: [], } ); - ( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithHttps ); ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../set-https' ); From 864c9b411275e6b3cb7d285f8f62863aa68429ab Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Mon, 1 Dec 2025 09:00:41 +0100 Subject: [PATCH 17/19] Disallow setting the same domain as before --- cli/commands/site/set-domain.ts | 5 +++++ cli/commands/site/tests/set-domain.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts index 883ad17527..3221aadc1a 100644 --- a/cli/commands/site/set-domain.ts +++ b/cli/commands/site/set-domain.ts @@ -41,6 +41,11 @@ export async function runCommand( sitePath: string, domainName: string ): Promis } oldDomainName = site.customDomain; + + if ( oldDomainName === domainName ) { + throw new LoggerError( __( 'The specified domain is already set for this site.' ) ); + } + site.customDomain = domainName; await saveAppdata( appdata ); } finally { diff --git a/cli/commands/site/tests/set-domain.test.ts b/cli/commands/site/tests/set-domain.test.ts index 654138bcc7..dd9ae5bfed 100644 --- a/cli/commands/site/tests/set-domain.test.ts +++ b/cli/commands/site/tests/set-domain.test.ts @@ -140,6 +140,20 @@ describe( 'Site Set-Domain Command', () => { expect( disconnect ).toHaveBeenCalled(); } ); + it( 'should throw error when new domain is identical to current domain', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...mockSiteData, customDomain: mockDomainName } ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-domain' ); + await runCommand( mockSiteFolder, mockDomainName ); + + expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + expect( saveAppdata ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + it( 'should handle site not found in appdata', async () => { ( readAppdata as jest.Mock ).mockResolvedValue( { sites: [], From 78ec8f1a406fcd6735e1e8bd8e65e05d33ad5fbd Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 2 Dec 2025 14:36:14 +0100 Subject: [PATCH 18/19] Update tests to follow new paradigm --- cli/commands/site/set-domain.ts | 22 +-- cli/commands/site/set-https.ts | 31 ++-- cli/commands/site/tests/set-domain.test.ts | 189 +++++++++------------ cli/commands/site/tests/set-https.test.ts | 105 +++++------- 4 files changed, 152 insertions(+), 195 deletions(-) diff --git a/cli/commands/site/set-domain.ts b/cli/commands/site/set-domain.ts index 3221aadc1a..87ff1f8689 100644 --- a/cli/commands/site/set-domain.ts +++ b/cli/commands/site/set-domain.ts @@ -14,9 +14,9 @@ import { import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( sitePath: string, domainName: string ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( sitePath: string, domainName: string ): Promise< void > { try { let site: SiteData; let oldDomainName: string | undefined; @@ -74,13 +74,6 @@ export async function runCommand( sitePath: string, domainName: string ): Promis await startWordPressServer( site ); logger.reportSuccess( __( 'Site restarted' ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -98,7 +91,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.domain ); + try { + await runCommand( argv.path, argv.domain ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/set-https.ts b/cli/commands/site/set-https.ts index 700d0c39d3..345d9d630a 100644 --- a/cli/commands/site/set-https.ts +++ b/cli/commands/site/set-https.ts @@ -1,14 +1,7 @@ import { __, _n } from '@wordpress/i18n'; import { arePathsEqual } from 'common/lib/fs-utils'; import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; -import { - getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - SiteData, - unlockAppdata, -} from 'cli/lib/appdata'; +import { lockAppdata, readAppdata, saveAppdata, SiteData, unlockAppdata } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { isServerRunning, @@ -18,9 +11,9 @@ import { import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( sitePath: string, enableHttps: boolean ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( sitePath: string, enableHttps: boolean ): Promise< void > { try { let site: SiteData; @@ -65,13 +58,6 @@ export async function runCommand( sitePath: string, enableHttps: boolean ): Prom await startWordPressServer( site ); logger.reportSuccess( __( 'Site restarted' ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -89,7 +75,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.enable ); + try { + await runCommand( argv.path, argv.enable ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/tests/set-domain.test.ts b/cli/commands/site/tests/set-domain.test.ts index dd9ae5bfed..8d079c18be 100644 --- a/cli/commands/site/tests/set-domain.test.ts +++ b/cli/commands/site/tests/set-domain.test.ts @@ -1,13 +1,14 @@ +import { getDomainNameValidationError } from 'common/lib/domains'; import { arePathsEqual } from 'common/lib/fs-utils'; import { SiteData, lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; -import { addDomainToHosts, removeDomainFromHosts } from 'cli/lib/hosts-file'; +import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { setupCustomDomain } from 'cli/lib/site-utils'; import { isServerRunning, startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; -import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), @@ -18,173 +19,121 @@ jest.mock( 'cli/lib/appdata', () => ( { } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); jest.mock( 'common/lib/fs-utils' ); jest.mock( 'cli/lib/hosts-file' ); +jest.mock( 'cli/lib/site-utils' ); +jest.mock( 'common/lib/domains' ); describe( 'Site Set-Domain Command', () => { - const mockSiteFolder = '/test/site/path'; - const mockDomainName = 'example.local'; - const mockSiteData: SiteData = { - id: 'test-site-id', + // Simple test data + const testSitePath = '/test/site/path'; + const testDomainName = 'example.local'; + + const createTestSite = (): SiteData => ( { + id: 'site-1', name: 'Test Site', - path: mockSiteFolder, + path: testSitePath, port: 8881, adminUsername: 'admin', adminPassword: 'password123', running: false, phpVersion: '8.0', - url: `http://localhost:8881`, - }; + } ); - const mockProcessDescription = { - name: 'test-site-id', - pmId: 0, - status: 'online', + const testProcessDescription = { pid: 12345, + status: 'online', }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; - }; + let testSite: SiteData; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + testSite = createTestSite(); ( connect as jest.Mock ).mockResolvedValue( undefined ); - ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( disconnect as jest.Mock ).mockResolvedValue( undefined ); ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ { ...mockSiteData } ], + sites: [ testSite ], snapshots: [], } ); ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - ( startWordPressServer as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( testProcessDescription ); ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); ( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b ); - ( addDomainToHosts as jest.Mock ).mockResolvedValue( undefined ); ( removeDomainFromHosts as jest.Mock ).mockResolvedValue( undefined ); + ( setupCustomDomain as jest.Mock ).mockResolvedValue( undefined ); + ( getDomainNameValidationError as jest.Mock ).mockReturnValue( '' ); } ); afterEach( () => { jest.restoreAllMocks(); } ); - describe( 'Error Handling', () => { - it( 'should handle PM2 connection failure', async () => { - ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + describe( 'Error Cases', () => { + it( 'should throw when site not found', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startDaemon', - 'Starting process daemon...' + await expect( runCommand( testSitePath, testDomainName ) ).rejects.toThrow( + 'The specified folder is not added to Studio.' ); - expect( mockLogger.reportError ).toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle appdata lock failure', async () => { - ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); - - const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should handle appdata read failure', async () => { - ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); - - const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); + it( 'should throw when domain is invalid', async () => { + ( getDomainNameValidationError as jest.Mock ).mockReturnValue( + 'Please enter a valid domain name' + ); - it( 'should handle domain validation error', async () => { const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, 'invalid domain' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, 'invalid domain' ) ).rejects.toThrow(); expect( saveAppdata ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should throw error when domain already exists', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ { ...mockSiteData, customDomain: 'example.local' } ], - snapshots: [], - } ); + it( 'should throw when domain already in use by another site', async () => { + ( getDomainNameValidationError as jest.Mock ).mockReturnValue( + 'The domain name is already in use' + ); const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, testDomainName ) ).rejects.toThrow(); expect( saveAppdata ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should throw error when new domain is identical to current domain', async () => { + it( 'should throw when domain is identical to current domain', async () => { ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ { ...mockSiteData, customDomain: mockDomainName } ], + sites: [ { ...testSite, customDomain: testDomainName } ], snapshots: [], } ); const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, testDomainName ) ).rejects.toThrow( + 'The specified domain is already set for this site.' + ); expect( saveAppdata ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle site not found in appdata', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [], - snapshots: [], - } ); - - const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should handle appdata save failure', async () => { + it( 'should throw when appdata save fails', async () => { ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should handle WordPress server stop failure', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); - ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); - - const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, testDomainName ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); } ); @@ -192,30 +141,54 @@ describe( 'Site Set-Domain Command', () => { describe( 'Success Cases', () => { it( 'should set domain on a stopped site', async () => { const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); + await runCommand( testSitePath, testDomainName ); + + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); expect( saveAppdata ).toHaveBeenCalled(); - const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].customDomain ).toBe( mockDomainName ); expect( unlockAppdata ).toHaveBeenCalled(); - expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].customDomain ).toBe( testDomainName ); + + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( removeDomainFromHosts ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should set domain and restart a running site', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../set-domain' ); - await runCommand( mockSiteFolder, mockDomainName ); + + await runCommand( testSitePath, testDomainName ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].customDomain ).toBe( mockDomainName ); - expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); - expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); - expect( startWordPressServer ).toHaveBeenCalled(); + expect( savedAppdata.sites[ 0 ].customDomain ).toBe( testDomainName ); + + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id ); + expect( setupCustomDomain ).toHaveBeenCalledWith( testSite, expect.any( Object ) ); + expect( startWordPressServer ).toHaveBeenCalledWith( testSite ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should remove old domain from hosts file when replacing domain', async () => { + const oldDomain = 'old.local'; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...testSite, customDomain: oldDomain } ], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-domain' ); + + await runCommand( testSitePath, testDomainName ); + + expect( removeDomainFromHosts ).toHaveBeenCalledWith( oldDomain ); expect( disconnect ).toHaveBeenCalled(); } ); } ); diff --git a/cli/commands/site/tests/set-https.test.ts b/cli/commands/site/tests/set-https.test.ts index b14293e553..58e06f4567 100644 --- a/cli/commands/site/tests/set-https.test.ts +++ b/cli/commands/site/tests/set-https.test.ts @@ -6,7 +6,6 @@ import { startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; -import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), @@ -17,12 +16,12 @@ jest.mock( 'cli/lib/appdata', () => ( { } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); jest.mock( 'common/lib/fs-utils' ); describe( 'Site Set-HTTPS Command', () => { const mockSiteFolder = '/test/site/path'; - const mockSiteData: SiteData = { + + const createMockSiteData = (): SiteData => ( { id: 'test-site-id', name: 'Test Site', path: mockSiteFolder, @@ -34,7 +33,7 @@ describe( 'Site Set-HTTPS Command', () => { url: `http://localhost:8881`, enableHttps: false, customDomain: 'test.local', - }; + } ); const mockProcessDescription = { name: 'test-site-id', @@ -43,28 +42,18 @@ describe( 'Site Set-HTTPS Command', () => { pid: 12345, }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; - }; + let mockSiteData: SiteData; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + mockSiteData = createMockSiteData(); ( 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 } ], + sites: [ mockSiteData ], snapshots: [], } ); ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); @@ -79,97 +68,92 @@ describe( 'Site Set-HTTPS Command', () => { jest.restoreAllMocks(); } ); - describe( 'Error Handling', () => { - it( 'should handle missing custom domain', async () => { - const siteWithoutDomain = { ...mockSiteData, customDomain: undefined }; + describe( 'Error Cases', () => { + it( 'should throw when site not found', async () => { ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ siteWithoutDomain ], + sites: [], snapshots: [], } ); const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, true ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should finish early if passing the same config value that the site already has', async () => { - const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, false ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'The specified folder is not added to Studio.' + ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle PM2 connection failure', async () => { - ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + it( 'should throw when site does not have a custom domain', async () => { + const siteWithoutDomain = { ...mockSiteData, customDomain: undefined }; + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ siteWithoutDomain ], + snapshots: [], + } ); const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'Site does not have a custom domain.' + ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle appdata lock failure', async () => { - ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); + it( 'should throw if passing the same config value that the site already has', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ { ...mockSiteData, enableHttps: true } ], + snapshots: [], + } ); const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow( + 'HTTPS is already enabled for this site.' + ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle appdata read failure', async () => { - ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); + it( 'should throw when appdata save fails', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle site not found in appdata', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [], - snapshots: [], - } ); + it( 'should throw when PM2 connection fails', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../set-https' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle WordPress server stop failure', async () => { + 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-https' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( mockSiteFolder, true ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); } ); describe( 'Success Cases', () => { it( 'should enable HTTPS on a stopped site', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); - expect( connect ).toHaveBeenCalled(); expect( lockAppdata ).toHaveBeenCalled(); expect( readAppdata ).toHaveBeenCalled(); expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + expect( unlockAppdata ).toHaveBeenCalled(); expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); expect( stopWordPressServer ).not.toHaveBeenCalled(); @@ -183,14 +167,15 @@ describe( 'Site Set-HTTPS Command', () => { sites: [ siteWithHttps ], snapshots: [], } ); - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, false ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( false ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); @@ -200,15 +185,16 @@ describe( 'Site Set-HTTPS Command', () => { ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, true ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -221,15 +207,16 @@ describe( 'Site Set-HTTPS Command', () => { ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); const { runCommand } = await import( '../set-https' ); + await runCommand( mockSiteFolder, false ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( false ); + expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); expect( startWordPressServer ).toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); expect( disconnect ).toHaveBeenCalled(); } ); } ); From c8ac692a550447354a46e81ac55ff3ffc2054e01 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 2 Dec 2025 14:48:16 +0100 Subject: [PATCH 19/19] Adopt new test paradigm --- cli/commands/site/set-php-version.ts | 24 ++-- .../site/tests/set-php-version.test.ts | 112 +++++++----------- 2 files changed, 58 insertions(+), 78 deletions(-) diff --git a/cli/commands/site/set-php-version.ts b/cli/commands/site/set-php-version.ts index 6721743770..a266a3aa70 100644 --- a/cli/commands/site/set-php-version.ts +++ b/cli/commands/site/set-php-version.ts @@ -20,15 +20,15 @@ import { StudioArgv } from 'cli/types'; const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; +const logger = new Logger< LoggerAction >(); + export async function runCommand( sitePath: string, phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ] ): Promise< void > { - const logger = new Logger< LoggerAction >(); - try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); - const site = await getSiteByFolder( sitePath, false ); + const site = await getSiteByFolder( sitePath ); logger.reportSuccess( __( 'Site loaded' ) ); if ( site.phpVersion === phpVersion ) { @@ -60,13 +60,6 @@ export async function runCommand( await startWordPressServer( site ); logger.reportSuccess( __( 'Site restarted' ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -85,7 +78,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.phpVersion ); + try { + await runCommand( argv.path, argv.phpVersion ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/tests/set-php-version.test.ts b/cli/commands/site/tests/set-php-version.test.ts index 2c83f8269d..2f30ba2a10 100644 --- a/cli/commands/site/tests/set-php-version.test.ts +++ b/cli/commands/site/tests/set-php-version.test.ts @@ -13,7 +13,6 @@ import { startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; -import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), @@ -25,15 +24,15 @@ jest.mock( 'cli/lib/appdata', () => ( { } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); jest.mock( 'common/lib/fs-utils' ); describe( 'Site Set-PHP-Version Command', () => { - const mockSiteFolder = '/test/site/path'; - const mockSiteData: SiteData = { + const testSitePath = '/test/site/path'; + + const createTestSite = (): SiteData => ( { id: 'test-site-id', name: 'Test Site', - path: mockSiteFolder, + path: testSitePath, port: 8881, adminUsername: 'admin', adminPassword: 'password123', @@ -42,44 +41,34 @@ describe( 'Site Set-PHP-Version Command', () => { url: `http://localhost:8881`, enableHttps: false, customDomain: 'test.local', - }; + } ); - const mockProcessDescription = { + const testProcessDescription = { name: 'test-site-id', pmId: 0, status: 'online', pid: 12345, }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; - }; + let testSite: SiteData; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; + testSite = createTestSite(); - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite ); ( 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 } ], + sites: [ testSite ], snapshots: [], } ); ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - ( startWordPressServer as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( testProcessDescription ); ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); ( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b ); } ); @@ -88,105 +77,94 @@ describe( 'Site Set-PHP-Version Command', () => { jest.restoreAllMocks(); } ); - describe( 'Error Handling', () => { - it( 'should finish early if passing the same PHP version that the site already has', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - - const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '8.0' ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should handle PM2 connection failure', async () => { - ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); - + describe( 'Error Cases', () => { + it( 'should throw when PHP version is identical to current version', async () => { const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + await expect( runCommand( testSitePath, '8.0' ) ).rejects.toThrow( + 'Site is already using the specified PHP version.' + ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle appdata lock failure', async () => { - ( lockAppdata as jest.Mock ).mockRejectedValue( new Error( 'Lock failed' ) ); + it( 'should throw when site not found in appdata', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow( + 'The specified folder is not added to Studio.' + ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle appdata read failure', async () => { - ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Read failed' ) ); + it( 'should throw when appdata save fails', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle site not found in appdata', async () => { - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [], - snapshots: [], - } ); + it( 'should throw when PM2 connection fails', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle WordPress server stop failure', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + it( 'should throw when WordPress server stop fails', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); expect( disconnect ).toHaveBeenCalled(); } ); } ); describe( 'Success Cases', () => { it( 'should update PHP version on a stopped site', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); - expect( connect ).toHaveBeenCalled(); + await runCommand( testSitePath, '7.4' ); + + expect( getSiteByFolder ).toHaveBeenCalledWith( testSitePath ); expect( lockAppdata ).toHaveBeenCalled(); expect( readAppdata ).toHaveBeenCalled(); expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); + expect( unlockAppdata ).toHaveBeenCalled(); - expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should update PHP version and restart a running site', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../set-php-version' ); - await runCommand( mockSiteFolder, '7.4' ); + + await runCommand( testSitePath, '7.4' ); expect( saveAppdata ).toHaveBeenCalled(); const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); - expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); - expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id ); + + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id ); expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site restarted' ); expect( disconnect ).toHaveBeenCalled(); } ); } );