diff --git a/cli/__mocks__/ora.ts b/cli/__mocks__/ora.ts index 8fd0a2841..4fc566896 100644 --- a/cli/__mocks__/ora.ts +++ b/cli/__mocks__/ora.ts @@ -1,6 +1,11 @@ -module.exports = { - start: jest.fn().mockReturnThis(), - stop: jest.fn().mockReturnThis(), - succeed: jest.fn().mockReturnThis(), - fail: jest.fn().mockReturnThis(), -}; +function mockOra() { + return { + start: jest.fn().mockReturnThis(), + stop: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + }; +} + +module.exports = mockOra; +module.exports.default = mockOra; diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 56f273c1d..6ffbbbccd 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -7,7 +7,6 @@ import { Blueprint } from '@wp-playground/blueprints'; import { RecommendedPHPVersion } from '@wp-playground/common'; import { filterUnsupportedBlueprintFeatures, - scanBlueprintForUnsupportedFeatures, validateBlueprintData, } from 'common/lib/blueprint-validation'; import { getDomainNameValidationError } from 'common/lib/domains'; @@ -35,6 +34,8 @@ const DEFAULT_VERSIONS = { const MINIMUM_WORDPRESS_VERSION = '6.2.1' as const; // https://wordpress.github.io/wordpress-playground/blueprints/examples/#load-an-older-wordpress-version const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; +const logger = new Logger< LoggerAction >(); + export async function runCommand( sitePath: string, options: { @@ -43,12 +44,10 @@ export async function runCommand( phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ]; customDomain?: string; enableHttps: boolean; - blueprint?: string; + blueprintJson?: unknown; noStart: boolean; } ): Promise< void > { - const logger = new Logger< LoggerAction >(); - try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating site configuration...' ) ); @@ -76,40 +75,24 @@ export async function runCommand( } let blueprint: Blueprint | undefined; - if ( options.blueprint ) { - if ( ! fs.existsSync( options.blueprint ) ) { - throw new LoggerError( sprintf( __( 'Blueprint file not found: %s' ), options.blueprint ) ); - } - let blueprintJson: Blueprint; - try { - const blueprintContent = fs.readFileSync( options.blueprint, 'utf-8' ); - blueprintJson = JSON.parse( blueprintContent ); - } catch ( error ) { - throw new LoggerError( - sprintf( __( 'Invalid blueprint JSON file: %s' ), options.blueprint ), - error - ); - } - const validation = await validateBlueprintData( blueprintJson ); + if ( options.blueprintJson ) { + const validation = await validateBlueprintData( options.blueprintJson ); if ( ! validation.valid ) { throw new LoggerError( validation.error ); } - const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( blueprintJson ); - if ( unsupportedFeatures.length > 0 ) { - for ( const feature of unsupportedFeatures ) { - logger.reportWarning( - sprintf( - /* translators: %1$s: feature name, %2$s: reason */ - __( `Blueprint feature "%1$s" is not supported: %2$s` ), - feature.name, - feature.reason - ) - ); - } + for ( const warning of validation.warnings ) { + logger.reportWarning( + sprintf( + /* translators: %1$s: feature name, %2$s: reason */ + __( `Blueprint feature "%1$s" is not supported: %2$s` ), + warning.feature, + warning.reason + ) + ); } - blueprint = filterUnsupportedBlueprintFeatures( blueprintJson ) as Blueprint; + blueprint = filterUnsupportedBlueprintFeatures( options.blueprintJson ) as Blueprint; } const appdata = await readAppdata(); @@ -213,7 +196,7 @@ export async function runCommand( await setupCustomDomain( siteDetails, logger ); - const startMessage = options.blueprint + const startMessage = blueprint ? __( 'Starting WordPress site and applying blueprint...' ) : __( 'Starting WordPress site...' ); logger.reportStart( LoggerAction.START_SITE, startMessage ); @@ -251,13 +234,6 @@ export async function runCommand( logSiteDetails( siteDetails ); console.log( __( 'Run "studio site start" to start the site.' ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to create site' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -304,15 +280,44 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.path, { - name: argv.name, - wpVersion: argv.wp, - phpVersion: argv.php, - customDomain: argv.domain, - enableHttps: argv.https, - blueprint: argv.blueprint, - noStart: ! argv.start, - } ); + try { + let blueprintJson: unknown; + + if ( argv.blueprint ) { + if ( ! fs.existsSync( argv.blueprint ) ) { + throw new LoggerError( + sprintf( __( 'Blueprint file not found: %s' ), argv.blueprint ) + ); + } + + try { + const blueprintContent = fs.readFileSync( argv.blueprint, 'utf-8' ); + blueprintJson = JSON.parse( blueprintContent ); + } catch ( error ) { + throw new LoggerError( + sprintf( __( 'Invalid blueprint JSON file: %s' ), argv.blueprint ), + error + ); + } + } + + await runCommand( argv.path, { + name: argv.name, + wpVersion: argv.wp, + phpVersion: argv.php, + customDomain: argv.domain, + enableHttps: argv.https, + blueprintJson: blueprintJson, + noStart: ! argv.start, + } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/list.ts b/cli/commands/site/list.ts index 8b707c94d..103069610 100644 --- a/cli/commands/site/list.ts +++ b/cli/commands/site/list.ts @@ -34,9 +34,9 @@ async function getSiteListData( sites: SiteData[] ) { return result; } -export async function runCommand( format: 'table' | 'json' ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( format: 'table' | 'json' ): Promise< void > { try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading sites…' ) ); const appdata = await readAppdata(); @@ -86,13 +86,6 @@ export async function runCommand( format: 'table' | 'json' ): Promise< void > { } else { console.log( JSON.stringify( sitesData, null, 2 ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to load sites' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -111,7 +104,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.format as 'table' | 'json' ); + try { + await runCommand( argv.format as 'table' | 'json' ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/start.ts b/cli/commands/site/start.ts index 3161a8d1d..f4d950da8 100644 --- a/cli/commands/site/start.ts +++ b/cli/commands/site/start.ts @@ -8,9 +8,9 @@ import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server- import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( sitePath: string, skipBrowser = false ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( sitePath: string, skipBrowser = false ): Promise< void > { try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); const site = await getSiteByFolder( sitePath ); @@ -58,13 +58,6 @@ export async function runCommand( sitePath: string, skipBrowser = false ): Promi } catch ( error ) { throw new LoggerError( __( 'Failed to start WordPress server' ), error ); } - } 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(); } @@ -82,7 +75,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.skipBrowser ); + try { + await runCommand( argv.path, argv.skipBrowser ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/status.ts b/cli/commands/site/status.ts index d8f794428..c6000e27a 100644 --- a/cli/commands/site/status.ts +++ b/cli/commands/site/status.ts @@ -9,9 +9,9 @@ import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( siteFolder: string, format: 'table' | 'json' ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( siteFolder: string, format: 'table' | 'json' ): Promise< void > { try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); const site = await getSiteByFolder( siteFolder ); @@ -69,13 +69,6 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) console.log( JSON.stringify( logData, null, 2 ) ); } - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to load site' ), error ); - logger.reportError( loggerError ); - } } finally { disconnect(); } @@ -88,13 +81,22 @@ export const registerCommand = ( yargs: StudioArgv ) => { builder: ( yargs ) => { return yargs.option( 'format', { type: 'string', - choices: [ 'table', 'json' ], - default: 'table', + choices: [ 'table', 'json' ] as const, + default: 'table' as const, description: __( 'Output format' ), } ); }, handler: async ( argv ) => { - await runCommand( argv.path, argv.format as 'table' | 'json' ); + try { + await runCommand( argv.path, argv.format ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/stop.ts b/cli/commands/site/stop.ts index 2f96cb353..405ebc491 100644 --- a/cli/commands/site/stop.ts +++ b/cli/commands/site/stop.ts @@ -7,9 +7,9 @@ import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-m import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( siteFolder: string ): Promise< void > { - const logger = new Logger< LoggerAction >(); +const logger = new Logger< LoggerAction >(); +export async function runCommand( siteFolder: string ): Promise< void > { try { const site = await getSiteByFolder( siteFolder ); @@ -30,13 +30,6 @@ export async function runCommand( siteFolder: string ): Promise< void > { } 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(); } @@ -50,7 +43,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { return yargs; }, handler: async ( argv ) => { - await runCommand( argv.path ); + try { + await runCommand( argv.path ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to load site' ), error ); + logger.reportError( loggerError ); + } + } }, } ); }; diff --git a/cli/commands/site/tests/create.test.ts b/cli/commands/site/tests/create.test.ts index fe6ca3889..f4ad6cb96 100644 --- a/cli/commands/site/tests/create.test.ts +++ b/cli/commands/site/tests/create.test.ts @@ -1,7 +1,6 @@ -import fs from 'fs'; +import { Blueprint } from '@wp-playground/blueprints'; import { filterUnsupportedBlueprintFeatures, - scanBlueprintForUnsupportedFeatures, validateBlueprintData, } from 'common/lib/blueprint-validation'; import { isEmptyDir, isWordPressDirectory, pathExists, arePathsEqual } from 'common/lib/fs-utils'; @@ -11,7 +10,6 @@ import { connect, disconnect } from 'cli/lib/pm2-manager'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { isSqliteIntegrationAvailable, installSqliteIntegration } from 'cli/lib/sqlite-integration'; import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-manager'; -import { Logger, LoggerError } from 'cli/logger'; jest.mock( 'common/lib/fs-utils' ); jest.mock( 'common/lib/port-finder', () => ( { @@ -37,7 +35,6 @@ jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/site-utils' ); jest.mock( 'cli/lib/sqlite-integration' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); describe( 'Site Create Command', () => { const mockSitePath = '/test/site/new-site'; @@ -66,34 +63,14 @@ describe( 'Site Create Command', () => { pid: 12345, }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; - reportWarning: jest.Mock; - }; - let consoleLogSpy: jest.SpyInstance; let fsMkdirSyncSpy: jest.SpyInstance; - let fsExistsSyncSpy: jest.SpyInstance; - let fsReadFileSyncSpy: jest.SpyInstance; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - reportWarning: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation(); - fsMkdirSyncSpy = jest.spyOn( fs, 'mkdirSync' ).mockReturnValue( undefined ); - fsExistsSyncSpy = jest.spyOn( fs, 'existsSync' ).mockReturnValue( false ); - fsReadFileSyncSpy = jest.spyOn( fs, 'readFileSync' ).mockReturnValue( '{}' ); + fsMkdirSyncSpy = jest.spyOn( require( 'fs' ), 'mkdirSync' ).mockReturnValue( undefined ); ( pathExists as jest.Mock ).mockResolvedValue( false ); ( isEmptyDir as jest.Mock ).mockResolvedValue( true ); ( isWordPressDirectory as jest.Mock ).mockReturnValue( false ); @@ -116,7 +93,6 @@ describe( 'Site Create Command', () => { ( logSiteDetails as jest.Mock ).mockImplementation( () => {} ); ( openSiteInBrowser as jest.Mock ).mockResolvedValue( undefined ); ( validateBlueprintData as jest.Mock ).mockResolvedValue( { valid: true, warnings: [] } ); - ( scanBlueprintForUnsupportedFeatures as jest.Mock ).mockReturnValue( [] ); ( filterUnsupportedBlueprintFeatures as jest.Mock ).mockImplementation( ( blueprint ) => blueprint ); @@ -134,77 +110,82 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'The selected directory is not empty nor an existing WordPress site.' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should error if WordPress version is invalid', async () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'invalid-version', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'invalid-version', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'Invalid WordPress version' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should error if WordPress version is below minimum', async () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: '5.0', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: '6.0', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'WordPress version must be at least' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should error if site path is already in use', async () => { ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [ { ...mockExistingSite, path: mockSitePath } ], + sites: [ mockExistingSite ], snapshots: [], } ); ( arePathsEqual as jest.Mock ).mockReturnValue( true ); const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'The selected directory is already in use.' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should error if custom domain is invalid', async () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - customDomain: 'invalid-domain-without-tld', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + customDomain: 'invalid-domain-without-tld', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow(); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -216,56 +197,20 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - customDomain: 'mysite.local', - enableHttps: false, - noStart: false, - } ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should error if blueprint file does not exist', async () => { - fsExistsSyncSpy.mockReturnValue( false ); - - const { runCommand } = await import( '../create' ); - - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - blueprint: '/path/to/nonexistent/blueprint.json', - enableHttps: false, - noStart: false, - } ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( disconnect ).toHaveBeenCalled(); - } ); - - it( 'should error if blueprint file contains invalid JSON', async () => { - fsExistsSyncSpy.mockReturnValue( true ); - fsReadFileSyncSpy.mockReturnValue( 'invalid json' ); - - const { runCommand } = await import( '../create' ); - - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + customDomain: 'mysite.local', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow(); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should error if blueprint validation fails', async () => { - fsExistsSyncSpy.mockReturnValue( true ); - fsReadFileSyncSpy.mockReturnValue( '{}' ); ( validateBlueprintData as jest.Mock ).mockResolvedValue( { valid: false, error: 'Invalid blueprint', @@ -273,15 +218,16 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + blueprintJson: {}, + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'Invalid blueprint' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -290,14 +236,15 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'SQLite integration files not found' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); } ); @@ -313,18 +260,12 @@ describe( 'Site Create Command', () => { noStart: false, } ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'validate', - 'Validating site configuration...' - ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site configuration validated' ); expect( fsMkdirSyncSpy ).toHaveBeenCalledWith( mockSitePath, { recursive: true } ); expect( isSqliteIntegrationAvailable ).toHaveBeenCalled(); expect( installSqliteIntegration ).toHaveBeenCalledWith( mockSitePath ); expect( portFinder.getOpenPort ).toHaveBeenCalled(); expect( lockAppdata ).toHaveBeenCalled(); expect( saveAppdata ).toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site created successfully' ); expect( connect ).toHaveBeenCalled(); expect( startWordPressServer ).toHaveBeenCalled(); expect( logSiteDetails ).toHaveBeenCalled(); @@ -402,7 +343,6 @@ describe( 'Site Create Command', () => { } ); expect( fsMkdirSyncSpy ).not.toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site created successfully' ); } ); it( 'should create site in existing WordPress directory', async () => { @@ -420,7 +360,6 @@ describe( 'Site Create Command', () => { } ); expect( fsMkdirSyncSpy ).not.toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site created successfully' ); } ); it( 'should create site with custom domain', async () => { @@ -530,14 +469,9 @@ describe( 'Site Create Command', () => { } ); describe( 'Blueprint Handling', () => { - beforeEach( () => { - fsExistsSyncSpy.mockReturnValue( true ); - fsReadFileSyncSpy.mockReturnValue( - JSON.stringify( { - steps: [ { step: 'installPlugin', pluginData: { slug: 'akismet' } } ], - } ) - ); - } ); + const testBlueprint: Blueprint = { + steps: [ { step: 'installPlugin', pluginData: { slug: 'akismet' } } ], + }; it( 'should apply blueprint when provided', async () => { const { runCommand } = await import( '../create' ); @@ -545,13 +479,11 @@ describe( 'Site Create Command', () => { await runCommand( mockSitePath, { wpVersion: 'latest', phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', + blueprintJson: testBlueprint, enableHttps: false, noStart: false, } ); - expect( fsExistsSyncSpy ).toHaveBeenCalledWith( '/path/to/blueprint.json' ); - expect( fsReadFileSyncSpy ).toHaveBeenCalledWith( '/path/to/blueprint.json', 'utf-8' ); expect( validateBlueprintData ).toHaveBeenCalled(); expect( startWordPressServer ).toHaveBeenCalledWith( expect.anything(), @@ -568,7 +500,7 @@ describe( 'Site Create Command', () => { name: 'My Site', wpVersion: 'latest', phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', + blueprintJson: testBlueprint, enableHttps: false, noStart: false, } ); @@ -589,25 +521,26 @@ describe( 'Site Create Command', () => { } ); it( 'should warn about unsupported blueprint features', async () => { - ( scanBlueprintForUnsupportedFeatures as jest.Mock ).mockReturnValue( [ - { - type: 'step', - name: 'login', - reason: 'Studio automatically creates and logs in the admin user', - }, - ] ); + ( validateBlueprintData as jest.Mock ).mockReturnValue( { + valid: true, + warnings: [ + { + type: 'step', + name: 'login', + reason: 'Studio automatically creates and logs in the admin user', + }, + ], + } ); const { runCommand } = await import( '../create' ); await runCommand( mockSitePath, { wpVersion: 'latest', phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', + blueprintJson: testBlueprint, enableHttps: false, noStart: false, } ); - - expect( mockLogger.reportWarning ).toHaveBeenCalled(); } ); } ); @@ -622,7 +555,6 @@ describe( 'Site Create Command', () => { noStart: true, } ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site created successfully' ); expect( connect ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); expect( setupCustomDomain ).not.toHaveBeenCalled(); @@ -632,15 +564,14 @@ describe( 'Site Create Command', () => { } ); it( 'should apply blueprint without starting server when noStart is true', async () => { - fsExistsSyncSpy.mockReturnValue( true ); - fsReadFileSyncSpy.mockReturnValue( JSON.stringify( { steps: [] } ) ); + const testBlueprint: Blueprint = { steps: [] }; const { runCommand } = await import( '../create' ); await runCommand( mockSitePath, { wpVersion: 'latest', phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', + blueprintJson: testBlueprint, enableHttps: false, noStart: true, } ); @@ -648,7 +579,6 @@ describe( 'Site Create Command', () => { expect( connect ).toHaveBeenCalled(); expect( runBlueprint ).toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Blueprint applied successfully' ); expect( consoleLogSpy ).toHaveBeenCalledWith( 'Run "studio site start" to start the site.' ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -659,34 +589,33 @@ describe( 'Site Create Command', () => { ( startWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server start failed' ) ); const { runCommand } = await import( '../create' ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow( 'Failed to start WordPress server' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should handle blueprint application failure', async () => { - fsExistsSyncSpy.mockReturnValue( true ); - fsReadFileSyncSpy.mockReturnValue( JSON.stringify( { steps: [] } ) ); + const testBlueprint: Blueprint = { steps: [] }; ( runBlueprint as jest.Mock ).mockRejectedValue( new Error( 'Blueprint failed' ) ); const { runCommand } = await import( '../create' ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + blueprintJson: testBlueprint, + enableHttps: false, + noStart: true, + } ) + ).rejects.toThrow( 'Failed to apply blueprint' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - blueprint: '/path/to/blueprint.json', - enableHttps: false, - noStart: true, - } ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); @@ -697,31 +626,17 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + await expect( + runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ) + ).rejects.toThrow(); - expect( mockLogger.reportError ).toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); - - it( 'should wrap non-LoggerError errors', async () => { - ( startWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Generic error' ) ); - - const { runCommand } = await import( '../create' ); - - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); - - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - } ); } ); describe( 'Cleanup', () => { @@ -730,12 +645,16 @@ describe( 'Site Create Command', () => { const { runCommand } = await import( '../create' ); - await runCommand( mockSitePath, { - wpVersion: 'latest', - phpVersion: '8.0', - enableHttps: false, - noStart: false, - } ); + try { + await runCommand( mockSitePath, { + wpVersion: 'latest', + phpVersion: '8.0', + enableHttps: false, + noStart: false, + } ); + } catch { + // Expected + } expect( disconnect ).toHaveBeenCalled(); } ); diff --git a/cli/commands/site/tests/list.test.ts b/cli/commands/site/tests/list.test.ts index cea1a2cf6..bc7df651a 100644 --- a/cli/commands/site/tests/list.test.ts +++ b/cli/commands/site/tests/list.test.ts @@ -1,19 +1,18 @@ import { readAppdata } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; 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(), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); describe( 'Sites List Command', () => { - const mockAppdata = { + // Simple test data + const testAppdata = { sites: [ { id: 'site-1', @@ -32,23 +31,15 @@ describe( 'Sites List Command', () => { snapshots: [], }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; + const emptyAppdata = { + sites: [], + snapshots: [], }; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - ( readAppdata as jest.Mock ).mockResolvedValue( mockAppdata ); + ( readAppdata as jest.Mock ).mockResolvedValue( testAppdata ); ( connect as jest.Mock ).mockResolvedValue( undefined ); ( disconnect as jest.Mock ).mockResolvedValue( undefined ); ( isServerRunning as jest.Mock ).mockResolvedValue( false ); @@ -58,64 +49,107 @@ describe( 'Sites List Command', () => { jest.restoreAllMocks(); } ); - it( 'should list sites successfully', async () => { - const { runCommand } = await import( '../list' ); - await runCommand( 'table' ); + describe( 'Error Cases', () => { + it( 'should throw when appdata read fails', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); - expect( readAppdata ).toHaveBeenCalled(); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'loadSites', 'Loading sites…' ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Found 2 sites' ); + const { runCommand } = await import( '../list' ); + + await expect( runCommand( 'table' ) ).rejects.toThrow( 'Failed to read appdata' ); + expect( disconnect ).toHaveBeenCalled(); + } ); } ); - it( 'should handle no sites found', async () => { - const { runCommand } = await import( '../list' ); - ( readAppdata as jest.Mock ).mockResolvedValue( { - sites: [], - snapshots: [], + describe( 'Success Cases', () => { + it( 'should list sites with table format', async () => { + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + const { runCommand } = await import( '../list' ); + + await runCommand( 'table' ); + + expect( readAppdata ).toHaveBeenCalled(); + expect( consoleSpy ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + } ); + + it( 'should list sites with json format', async () => { + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + const { runCommand } = await import( '../list' ); + + await runCommand( 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( + JSON.stringify( + [ + { + status: '🔴 Offline', + name: 'Test Site 1', + path: '/path/to/site1', + url: 'http://localhost:8080', + }, + { + status: '🔴 Offline', + name: 'Test Site 2', + path: '/path/to/site2', + url: 'http://my-site.wp.local', + }, + ], + null, + 2 + ) + ); + expect( disconnect ).toHaveBeenCalled(); + + consoleSpy.mockRestore(); } ); - await runCommand( 'table' ); + it( 'should handle no sites found', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( emptyAppdata ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( 'loadSites', 'Loading sites…' ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'No sites found' ); - } ); + const { runCommand } = await import( '../list' ); - it( 'should handle appdata read errors', async () => { - const { runCommand } = await import( '../list' ); - ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); + await runCommand( 'table' ); - await runCommand( 'table' ); + expect( readAppdata ).toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + it( 'should handle custom domain in site URL', async () => { + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + const { runCommand } = await import( '../list' ); + + await runCommand( 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( expect.stringContaining( 'my-site.wp.local' ) ); + expect( disconnect ).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + } ); } ); - it( 'should work with json format', async () => { - const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); - const { runCommand } = await import( '../list' ); - await runCommand( 'json' ); - - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Found 2 sites' ); - expect( consoleSpy ).toHaveBeenCalledWith( - JSON.stringify( - [ - { - status: '🔴 Offline', - name: 'Test Site 1', - path: '/path/to/site1', - url: 'http://localhost:8080', - }, - { - status: '🔴 Offline', - name: 'Test Site 2', - path: '/path/to/site2', - url: 'http://my-site.wp.local', - }, - ], - null, - 2 - ) - ); - - consoleSpy.mockRestore(); + describe( 'Cleanup', () => { + it( 'should always disconnect from PM2 on success', async () => { + const { runCommand } = await import( '../list' ); + + await runCommand( 'table' ); + + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should always disconnect from PM2 on error', async () => { + ( readAppdata as jest.Mock ).mockRejectedValue( new Error( 'Error' ) ); + + const { runCommand } = await import( '../list' ); + + try { + await runCommand( 'table' ); + } catch { + // Expected + } + + expect( disconnect ).toHaveBeenCalled(); + } ); } ); } ); diff --git a/cli/commands/site/tests/start.test.ts b/cli/commands/site/tests/start.test.ts index e029fc96b..f621b521b 100644 --- a/cli/commands/site/tests/start.test.ts +++ b/cli/commands/site/tests/start.test.ts @@ -1,380 +1,247 @@ import { getSiteByFolder, updateSiteLatestCliPid, 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 { connect, disconnect, isProxyProcessRunning, startProxyProcess } from 'cli/lib/pm2-manager'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; import { isServerRunning, startWordPressServer } 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' ), getSiteByFolder: jest.fn(), updateSiteLatestCliPid: jest.fn(), - getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), } ) ); -jest.mock( 'cli/lib/browser' ); -jest.mock( 'cli/lib/certificate-manager' ); -jest.mock( 'cli/lib/hosts-file' ); jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/site-utils' ); jest.mock( 'cli/lib/wordpress-server-manager' ); -jest.mock( 'cli/logger' ); jest.mock( 'cli/lib/sqlite-integration' ); -jest.mock( 'common/lib/fs-utils' ); describe( 'Site Start Command', () => { - const mockSiteFolder = '/test/site/path'; - const mockSiteData: SiteData = { - id: 'test-site-id', + // Simple test data + const testSite: SiteData = { + id: 'site-1', name: 'Test Site', - path: mockSiteFolder, - port: 8881, + path: '/test/site', + port: 8080, + phpVersion: '8.0', adminUsername: 'admin', adminPassword: 'password123', - running: false, - phpVersion: '8.0', - url: `http://localhost:8881`, - }; - - const mockSiteDataWithCustomDomain: SiteData = { - ...mockSiteData, - customDomain: 'testsite.local', - }; - - const mockSiteDataWithHttps: SiteData = { - ...mockSiteDataWithCustomDomain, - enableHttps: true, }; - const mockSiteDataWithExistingCerts: SiteData = { - ...mockSiteDataWithHttps, - tlsKey: '/path/to/key.pem', - tlsCert: '/path/to/cert.pem', + const testSiteWithDomain: SiteData = { + ...testSite, + customDomain: 'test.local', }; - const mockProcessDescription = { - name: 'test-site-id', - pmId: 0, - status: 'online', + const testProcessDescription = { pid: 12345, - }; - - const mockProxyProcess = { - name: 'studio-proxy', - pmId: 1, status: 'online', - pid: 54321, - }; - - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; }; - let consoleLogSpy: jest.SpyInstance; - beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - - // Mock process.exit to prevent test termination - jest.spyOn( process, 'exit' ).mockImplementation( () => { - throw new Error( 'process.exit called' ); - } ); - - // Mock console.log for logSiteDetails - consoleLogSpy = jest.spyOn( console, 'log' ).mockImplementation(); - - // Default mock implementations + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite ); ( connect as jest.Mock ).mockResolvedValue( undefined ); - ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( disconnect as jest.Mock ).mockResolvedValue( undefined ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( undefined ); - ( startProxyProcess as jest.Mock ).mockResolvedValue( mockProxyProcess ); - ( startWordPressServer as jest.Mock ).mockResolvedValue( mockProcessDescription ); - ( updateSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); - ( openBrowser as jest.Mock ).mockResolvedValue( undefined ); - ( generateSiteCertificate as jest.Mock ).mockResolvedValue( undefined ); - ( addDomainToHosts as jest.Mock ).mockResolvedValue( undefined ); + ( setupCustomDomain as jest.Mock ).mockResolvedValue( undefined ); ( keepSqliteIntegrationUpdated as jest.Mock ).mockResolvedValue( undefined ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( testProcessDescription ); + ( updateSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); + ( logSiteDetails as jest.Mock ).mockImplementation( () => {} ); + ( openSiteInBrowser as jest.Mock ).mockResolvedValue( undefined ); } ); afterEach( () => { jest.restoreAllMocks(); } ); - describe( 'Error Handling', () => { - it( 'should handle site not found error', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( null ); + describe( 'Error Cases', () => { + it( 'should throw when site not found', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( '/invalid/path' ) ).rejects.toThrow( 'Site not found' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle PM2 connection failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + it( 'should throw when PM2 connection fails', async () => { ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startDaemon', - 'Starting process daemon...' - ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'PM2 connection failed' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle WordPress server start failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + it( 'should throw when WordPress server fails to start', async () => { ( startWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server start failed' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startSite', - 'Starting WordPress site...' + await expect( runCommand( '/test/site' ) ).rejects.toThrow( + 'Failed to start WordPress server' ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle hosts file update failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithCustomDomain ); - ( addDomainToHosts as jest.Mock ).mockRejectedValue( new Error( 'Hosts file error' ) ); + it( 'should throw when custom domain setup fails', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSiteWithDomain ); + ( setupCustomDomain as jest.Mock ).mockRejectedValue( + new Error( ' Custom domain setup failed' ) + ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'addDomainToHosts', - 'Adding domain to hosts file...' - ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'Custom domain setup failed' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle certificate generation failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithHttps ); - ( generateSiteCertificate as jest.Mock ).mockRejectedValue( - new Error( 'Certificate generation failed' ) + it( 'should throw when SQLite integration setup fails', async () => { + ( keepSqliteIntegrationUpdated as jest.Mock ).mockRejectedValue( + new Error( 'SQLite setup failed' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'generateCert', - 'Generating SSL certificates...' - ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'SQLite setup failed' ); expect( disconnect ).toHaveBeenCalled(); } ); - } ); - describe( 'Success Cases', () => { - it( 'should skip startup if server is already running', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + it( 'should throw when browser fails', async () => { + ( openSiteInBrowser as jest.Mock ).mockRejectedValue( new Error( 'Browser error' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( - 'WordPress site is already running' - ); - expect( updateSiteLatestCliPid ).toHaveBeenCalledWith( - mockSiteData.id, - mockProcessDescription.pid - ); - expect( startWordPressServer ).not.toHaveBeenCalled(); - expect( consoleLogSpy ).toHaveBeenCalledWith( 'Site URL: ', mockSiteData.url ); - expect( consoleLogSpy ).toHaveBeenCalledWith( 'Username: ', 'admin' ); - expect( consoleLogSpy ).toHaveBeenCalledWith( 'Password: ', mockSiteData.adminPassword ); - expect( openBrowser ).toHaveBeenCalled(); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'Browser error' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should start basic site without custom domain', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + it( 'should throw when browser fails while already running', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); + ( openSiteInBrowser as jest.Mock ).mockRejectedValue( new Error( 'Browser error' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startDaemon', - 'Starting process daemon...' - ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Process daemon started' ); - expect( connect ).toHaveBeenCalled(); - expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id ); - expect( startProxyProcess ).not.toHaveBeenCalled(); - expect( generateSiteCertificate ).not.toHaveBeenCalled(); - expect( addDomainToHosts ).not.toHaveBeenCalled(); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startSite', - 'Starting WordPress site...' - ); - expect( startWordPressServer ).toHaveBeenCalledWith( mockSiteData ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site started' ); - expect( updateSiteLatestCliPid ).toHaveBeenCalledWith( - mockSiteData.id, - mockProcessDescription.pid - ); - expect( consoleLogSpy ).toHaveBeenCalledWith( 'Site URL: ', mockSiteData.url ); - expect( openBrowser ).toHaveBeenCalled(); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'Browser error' ); expect( disconnect ).toHaveBeenCalled(); } ); + } ); - it( 'should start site with custom domain (HTTP)', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithCustomDomain ); - + describe( 'Success Cases', () => { + it( 'should start a basic site', async () => { const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'startProxy', - 'Starting HTTP proxy server...' - ); - expect( isProxyProcessRunning ).toHaveBeenCalled(); - expect( startProxyProcess ).toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'HTTP proxy server started' ); - expect( generateSiteCertificate ).not.toHaveBeenCalled(); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'addDomainToHosts', - 'Adding domain to hosts file...' - ); - expect( addDomainToHosts ).toHaveBeenCalledWith( - mockSiteDataWithCustomDomain.customDomain, - mockSiteDataWithCustomDomain.port + await runCommand( '/test/site' ); + + expect( getSiteByFolder ).toHaveBeenCalledWith( '/test/site' ); + expect( connect ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( setupCustomDomain ).toHaveBeenCalledWith( testSite, expect.any( Object ) ); + expect( keepSqliteIntegrationUpdated ).toHaveBeenCalledWith( '/test/site' ); + expect( startWordPressServer ).toHaveBeenCalledWith( testSite ); + expect( updateSiteLatestCliPid ).toHaveBeenCalledWith( + testSite.id, + testProcessDescription.pid ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Domain added to hosts file' ); - expect( startWordPressServer ).toHaveBeenCalledWith( mockSiteDataWithCustomDomain ); + expect( logSiteDetails ).toHaveBeenCalledWith( testSite ); + expect( openSiteInBrowser ).toHaveBeenCalledWith( testSite ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should start site with custom domain (HTTPS) and generate certificate', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithHttps ); + it( 'should skip server start if already running', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'generateCert', - 'Generating SSL certificates...' + await runCommand( '/test/site' ); + + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( setupCustomDomain ).not.toHaveBeenCalled(); + expect( updateSiteLatestCliPid ).toHaveBeenCalledWith( + testSite.id, + testProcessDescription.pid ); - expect( generateSiteCertificate ).toHaveBeenCalledWith( mockSiteDataWithHttps.customDomain ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'SSL certificates generated' ); - expect( addDomainToHosts ).toHaveBeenCalled(); - expect( startWordPressServer ).toHaveBeenCalledWith( mockSiteDataWithHttps ); + expect( openSiteInBrowser ).toHaveBeenCalledWith( testSite ); + expect( logSiteDetails ).toHaveBeenCalledWith( testSite ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should not generate certificate if it already exists', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithExistingCerts ); + it( 'should setup custom domain when present', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSiteWithDomain ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - - expect( generateSiteCertificate ).not.toHaveBeenCalled(); - expect( addDomainToHosts ).toHaveBeenCalled(); - expect( startWordPressServer ).toHaveBeenCalledWith( mockSiteDataWithExistingCerts ); - expect( disconnect ).toHaveBeenCalled(); - } ); - it( 'should skip proxy start if proxy is already running', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteDataWithCustomDomain ); - ( isProxyProcessRunning as jest.Mock ).mockResolvedValue( mockProxyProcess ); - - const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); + await runCommand( '/test/site' ); - expect( isProxyProcessRunning ).toHaveBeenCalled(); - expect( startProxyProcess ).not.toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'HTTP proxy already running' ); + expect( setupCustomDomain ).toHaveBeenCalledWith( testSiteWithDomain, expect.any( Object ) ); + expect( startWordPressServer ).toHaveBeenCalledWith( testSiteWithDomain ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should skip browser when skipBrowser flag is true', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - + it( 'should update SQLite integration', async () => { const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder, true ); - expect( startWordPressServer ).toHaveBeenCalledWith( mockSiteData ); - expect( openBrowser ).not.toHaveBeenCalled(); - expect( consoleLogSpy ).toHaveBeenCalled(); - expect( disconnect ).toHaveBeenCalled(); - } ); + await runCommand( '/test/site' ); - it( 'should skip browser when server is already running and skipBrowser is true', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + expect( keepSqliteIntegrationUpdated ).toHaveBeenCalledWith( '/test/site' ); + } ); + it( 'should skip browser when skipBrowser is true', async () => { const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder, true ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( - 'WordPress site is already running' - ); - expect( openBrowser ).not.toHaveBeenCalled(); + await runCommand( '/test/site', true ); + + expect( startWordPressServer ).toHaveBeenCalledWith( testSite ); + expect( openSiteInBrowser ).not.toHaveBeenCalled(); + expect( logSiteDetails ).toHaveBeenCalledWith( testSite ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should not fail if browser fails to open', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( openBrowser as jest.Mock ).mockRejectedValue( new Error( 'Browser error' ) ); + it( 'should skip browser when already running and skipBrowser is true', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); - expect( startWordPressServer ).toHaveBeenCalled(); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site started' ); + await runCommand( '/test/site', true ); + + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( openSiteInBrowser ).not.toHaveBeenCalled(); + expect( logSiteDetails ).toHaveBeenCalledWith( testSite ); expect( disconnect ).toHaveBeenCalled(); - // Should not call reportError for browser failure - expect( mockLogger.reportError ).not.toHaveBeenCalled(); } ); } ); describe( 'Cleanup', () => { - it( 'should disconnect from PM2 even on error', async () => { - ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Appdata error' ) ); - + it( 'should always disconnect from PM2 on success', async () => { const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); + + await runCommand( '/test/site' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should disconnect from PM2 on success', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + it( 'should always disconnect from PM2 on error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Error' ) ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); + + try { + await runCommand( '/test/site' ); + } catch { + // Expected + } expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should disconnect from PM2 even if server was already running', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + it( 'should always disconnect when already running', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../start' ); - await runCommand( mockSiteFolder ); + + await runCommand( '/test/site' ); expect( disconnect ).toHaveBeenCalled(); } ); diff --git a/cli/commands/site/tests/status.test.ts b/cli/commands/site/tests/status.test.ts index 9ed916384..f0c8fc7d7 100644 --- a/cli/commands/site/tests/status.test.ts +++ b/cli/commands/site/tests/status.test.ts @@ -2,23 +2,20 @@ import { getWordPressVersion } from 'common/lib/get-wordpress-version'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; -import { Logger } from 'cli/logger'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), getSiteByFolder: jest.fn(), getSiteUrl: jest.fn(), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/wordpress-server-manager' ); jest.mock( 'common/lib/get-wordpress-version' ); -jest.mock( 'cli/lib/utils', () => ( { - getPrettyPath: ( path: string ) => path.replace( /^\//, '' ), -} ) ); -jest.mock( 'cli/logger' ); describe( 'Site Status Command', () => { - const mockSite = { + // Simple test data + const testSite = { id: 'site-1', name: 'Test Site', path: '/path/to/site', @@ -27,23 +24,16 @@ describe( 'Site Status Command', () => { adminPassword: 'password123', }; - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; + const testSiteWithoutOptional = { + id: 'site-1', + path: '/path/to/site', + adminPassword: undefined, }; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSite ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite ); ( getSiteUrl as jest.Mock ).mockReturnValue( 'http://localhost:8080' ); ( connect as jest.Mock ).mockResolvedValue( undefined ); ( disconnect as jest.Mock ).mockResolvedValue( undefined ); @@ -55,120 +45,150 @@ describe( 'Site Status Command', () => { jest.restoreAllMocks(); } ); - it( 'should show site status successfully with table format', async () => { - const { runCommand } = await import( '../status' ); - await runCommand( '/path/to/site', 'table' ); + describe( 'Error Cases', () => { + it( 'should throw when site not found', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); - expect( getSiteByFolder ).toHaveBeenCalledWith( '/path/to/site' ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( expect.any( String ), 'Loading site…' ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); - expect( disconnect ).toHaveBeenCalled(); - } ); + const { runCommand } = await import( '../status' ); - it( 'should show site status with json format', async () => { - const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); - const { runCommand } = await import( '../status' ); - await runCommand( '/path/to/site', 'json' ); - - expect( getSiteByFolder ).toHaveBeenCalledWith( '/path/to/site' ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'Site loaded' ); - expect( consoleSpy ).toHaveBeenCalledWith( - JSON.stringify( - { - 'Site URL': 'http://localhost:8080/', - 'Site Path': 'path/to/site', - Status: '🔴 Offline', - 'PHP Version': '8.0', - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': 'password123', - }, - null, - 2 - ) - ); - - consoleSpy.mockRestore(); + await expect( runCommand( '/invalid/path', 'table' ) ).rejects.toThrow( 'Site not found' ); + expect( disconnect ).toHaveBeenCalled(); + } ); } ); - it( 'should show online status when server is running', async () => { - ( isServerRunning as jest.Mock ).mockResolvedValue( true ); - const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); - const { runCommand } = await import( '../status' ); - await runCommand( '/path/to/site', 'json' ); - - expect( consoleSpy ).toHaveBeenCalledWith( - JSON.stringify( - { - 'Site URL': 'http://localhost:8080/', - 'Auto Login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', - 'Site Path': 'path/to/site', - Status: '🟢 Online', - 'PHP Version': '8.0', - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': 'password123', - }, - null, - 2 - ) - ); - - consoleSpy.mockRestore(); + describe( 'Success Cases', () => { + it( 'should display site status with table format', async () => { + const { runCommand } = await import( '../status' ); + + await runCommand( '/path/to/site', 'table' ); + + expect( getSiteByFolder ).toHaveBeenCalledWith( '/path/to/site' ); + expect( connect ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should output JSON format correctly', async () => { + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + const { runCommand } = await import( '../status' ); + + await runCommand( '/path/to/site', 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( + JSON.stringify( + { + 'Site URL': 'http://localhost:8080/', + 'Site Path': '/path/to/site', + Status: '🔴 Offline', + 'PHP Version': '8.0', + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': 'password123', + }, + null, + 2 + ) + ); + + consoleSpy.mockRestore(); + } ); + + it( 'should show online status when server is running', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( { pid: 12345 } ); + + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + const { runCommand } = await import( '../status' ); + + await runCommand( '/path/to/site', 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( + JSON.stringify( + { + 'Site URL': 'http://localhost:8080/', + 'Auto Login URL': 'http://localhost:8080/studio-auto-login?redirect_to=%2Fwp-admin%2F', + 'Site Path': '/path/to/site', + Status: '🟢 Online', + 'PHP Version': '8.0', + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': 'password123', + }, + null, + 2 + ) + ); + + consoleSpy.mockRestore(); + } ); + + it( 'should handle custom domain in site URL', async () => { + ( getSiteUrl as jest.Mock ).mockReturnValue( 'http://my-site.wp.local' ); + + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + const { runCommand } = await import( '../status' ); + + await runCommand( '/path/to/site', 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( + expect.stringContaining( 'http://my-site.wp.local' ) + ); + + consoleSpy.mockRestore(); + } ); + + it( 'should handle missing optional site properties', async () => { + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSiteWithoutOptional ); + + const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); + + const { runCommand } = await import( '../status' ); + + await runCommand( '/path/to/site', 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( + JSON.stringify( + { + 'Site URL': 'http://localhost:8080/', + 'Site Path': '/path/to/site', + Status: '🔴 Offline', + 'PHP Version': undefined, + 'WP Version': '6.4', + 'Admin Username': 'admin', + 'Admin Password': undefined, + }, + null, + 2 + ) + ); + + consoleSpy.mockRestore(); + } ); } ); - it( 'should handle custom domain in site URL', async () => { - ( getSiteUrl as jest.Mock ).mockReturnValue( 'http://my-site.wp.local' ); - - const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); - const { runCommand } = await import( '../status' ); - await runCommand( '/path/to/site', 'json' ); + describe( 'Cleanup', () => { + it( 'should always disconnect from PM2 on success', async () => { + const { runCommand } = await import( '../status' ); - expect( consoleSpy ).toHaveBeenCalledWith( - expect.stringContaining( 'http://my-site.wp.local' ) - ); + await runCommand( '/path/to/site', 'table' ); - consoleSpy.mockRestore(); - } ); + expect( disconnect ).toHaveBeenCalled(); + } ); - it( 'should handle site not found error', async () => { - const { runCommand } = await import( '../status' ); - ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); + it( 'should always disconnect from PM2 on error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Error' ) ); - await runCommand( '/invalid/path', 'table' ); + const { runCommand } = await import( '../status' ); - expect( mockLogger.reportError ).toHaveBeenCalled(); - expect( disconnect ).toHaveBeenCalled(); - } ); + try { + await runCommand( '/path/to/site', 'table' ); + } catch { + // Expected + } - it( 'should handle missing optional site properties', async () => { - const minimalSite = { - id: 'site-1', - path: '/path/to/site', - adminPassword: undefined, - }; - ( getSiteByFolder as jest.Mock ).mockResolvedValue( minimalSite ); - - const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation(); - const { runCommand } = await import( '../status' ); - await runCommand( '/path/to/site', 'json' ); - - expect( consoleSpy ).toHaveBeenCalledWith( - JSON.stringify( - { - 'Site URL': 'http://localhost:8080/', - 'Site Path': 'path/to/site', - Status: '🔴 Offline', - 'PHP Version': undefined, - 'WP Version': '6.4', - 'Admin Username': 'admin', - 'Admin Password': undefined, - }, - null, - 2 - ) - ); - - consoleSpy.mockRestore(); + expect( disconnect ).toHaveBeenCalled(); + } ); } ); } ); diff --git a/cli/commands/site/tests/stop.test.ts b/cli/commands/site/tests/stop.test.ts index 7b2975602..495e6b259 100644 --- a/cli/commands/site/tests/stop.test.ts +++ b/cli/commands/site/tests/stop.test.ts @@ -2,59 +2,40 @@ import { SiteData, clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdat 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'; jest.mock( 'cli/lib/appdata', () => ( { ...jest.requireActual( 'cli/lib/appdata' ), - getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), getSiteByFolder: jest.fn(), clearSiteLatestCliPid: jest.fn(), - getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ), + getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ), } ) ); jest.mock( 'cli/lib/pm2-manager' ); jest.mock( 'cli/lib/site-utils' ); 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', + // Simple test data + const testSite: SiteData = { + id: 'site-1', name: 'Test Site', - path: mockSiteFolder, - port: 8881, + path: '/test/site', + port: 8080, + phpVersion: '8.0', adminUsername: 'admin', adminPassword: 'password123', - running: true, - phpVersion: '8.0', - url: `http://localhost:8881`, }; - const mockProcessDescription = { - name: 'test-site-id', - pmId: 0, - status: 'online', + const testProcessDescription = { pid: 12345, - }; - - let mockLogger: { - reportStart: jest.Mock; - reportSuccess: jest.Mock; - reportError: jest.Mock; + status: 'online', }; beforeEach( () => { jest.clearAllMocks(); - mockLogger = { - reportStart: jest.fn(), - reportSuccess: jest.fn(), - reportError: jest.fn(), - }; - - ( Logger as jest.Mock ).mockReturnValue( mockLogger ); + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite ); ( connect as jest.Mock ).mockResolvedValue( undefined ); - ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( disconnect as jest.Mock ).mockResolvedValue( undefined ); ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); ( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined ); @@ -65,125 +46,103 @@ describe( 'Site Stop Command', () => { jest.restoreAllMocks(); } ); - describe( 'Error Handling', () => { - it( 'should handle site not found error', async () => { + describe( 'Error Cases', () => { + it( 'should throw when site not found', async () => { ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) ); const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); + await expect( runCommand( '/invalid/path' ) ).rejects.toThrow( 'Site not found' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle PM2 connection failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); + it( 'should throw when PM2 connection fails', async () => { ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportError ).toHaveBeenCalled(); + await expect( runCommand( '/test/site' ) ).rejects.toThrow( 'PM2 connection failed' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should handle WordPress server stop failure', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( 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( '../stop' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportStart ).toHaveBeenCalledWith( - 'stopSite', - 'Stopping WordPress site...' + await expect( runCommand( '/test/site' ) ).rejects.toThrow( + 'Failed to stop WordPress server' ); - 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 () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - + it( 'should skip stop if server is not running', async () => { const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); - expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site is not running' ); + await runCommand( '/test/site' ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( clearSiteLatestCliPid ).not.toHaveBeenCalled(); + expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled(); expect( disconnect ).toHaveBeenCalled(); } ); it( 'should stop a running site', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); + await runCommand( '/test/site' ); + + expect( getSiteByFolder ).toHaveBeenCalledWith( '/test/site' ); 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( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id ); + expect( clearSiteLatestCliPid ).toHaveBeenCalledWith( testSite.id ); + expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalledWith( testSite.id, expect.any( Object ) ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should call stopProxyIfNoSitesNeedIt after stopping a site', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( 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 () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); + + await runCommand( '/test/site' ); expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); } ); } ); describe( 'Cleanup', () => { - it( 'should disconnect from PM2 even on error', async () => { - ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site error' ) ); - + it( 'should always disconnect from PM2 on success', async () => { const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); + + await runCommand( '/test/site' ); expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should disconnect from PM2 on success', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription ); + it( 'should always disconnect from PM2 on error', async () => { + ( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Error' ) ); const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); + + try { + await runCommand( '/test/site' ); + } catch { + // Expected + } expect( disconnect ).toHaveBeenCalled(); } ); - it( 'should disconnect from PM2 even if server was not running', async () => { - ( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData ); - ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); - + it( 'should always disconnect when site is not running', async () => { const { runCommand } = await import( '../stop' ); - await runCommand( mockSiteFolder ); + + await runCommand( '/test/site' ); expect( disconnect ).toHaveBeenCalled(); } ); diff --git a/common/lib/blueprint-validation.ts b/common/lib/blueprint-validation.ts index 3b1e52231..e5f516e02 100644 --- a/common/lib/blueprint-validation.ts +++ b/common/lib/blueprint-validation.ts @@ -1,5 +1,6 @@ import { __ } from '@wordpress/i18n'; import { compileBlueprint } from '@wp-playground/blueprints'; +import { z } from 'zod'; interface UnsupportedFeature { type: 'step' | 'property'; @@ -136,15 +137,29 @@ export type BlueprintValidationResult = BlueprintValidationError | BlueprintVali * Validates a blueprint by compiling it and scanning for unsupported features. */ export async function validateBlueprintData( - blueprintJson: BlueprintData + blueprintJson: unknown ): Promise< BlueprintValidationResult > { // Temporarily suppress console.warn during blueprint compilation // to avoid noisy deprecation warnings from @wp-playground/blueprints const originalWarn = console.warn; console.warn = () => {}; + const schema = z.record( z.string(), z.any() ); + try { - await compileBlueprint( blueprintJson ); + const result = schema.parse( blueprintJson ); + await compileBlueprint( result ); + + const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( result ); + const warnings = unsupportedFeatures.map( ( feature ) => ( { + feature: feature.name, + reason: feature.reason, + } ) ); + + return { + valid: true, + warnings, + }; } catch ( error ) { const errorMessage = error instanceof Error ? error.message : __( 'Invalid Blueprint format' ); return { @@ -154,16 +169,4 @@ export async function validateBlueprintData( } finally { console.warn = originalWarn; } - - const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( blueprintJson ); - - const warnings = unsupportedFeatures.map( ( feature ) => ( { - feature: feature.name, - reason: feature.reason, - } ) ); - - return { - valid: true, - warnings, - }; }