diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index f4323c2e9afad..d69eea93d50ab 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -4,7 +4,13 @@ ### Breaking Change -- Rework`run` command to resolve bugs with non-quoted commands. As a consequence it is no longer possible to pass your entire command to `wp-env` wrapped in double-quotes. While `npx wp-env run cli wp help` will still work, `npx wp-env run cli "wp help"` will not. If you are currently escaping any quotes you will need to review those commands and ensure they are compatible with this update. +- Remove `afterSetup` option from `.wp-env.json` and the `WP_ENV_AFTER_SETUP` environment variable in favor of more granular lifecycle scripts. + +### New feature + +- Add `afterStart`, `afterClean`, and `afterDestroy` lifecycle scripts to a new `lifecycleScripts` key in `.wp-env.json`. +- Add a series of `WP_ENV_LIFECYCLE_SCRIPT_` environment variables for the various lifecycle scripts. +- Rework `run` command to resolve bugs with non-quoted commands. As a consequence it is no longer possible to pass your entire command to `wp-env` wrapped in double-quotes. While `npx wp-env run cli wp help` will still work, `npx wp-env run cli "wp help"` will not. If you are currently escaping any quotes you will need to review those commands and ensure they are compatible with this update. ### Enhancement diff --git a/packages/env/README.md b/packages/env/README.md index c68425fb63b11..0bc3609283033 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -441,7 +441,8 @@ Destroy the WordPress environment. Deletes docker containers, volumes, and networks associated with the WordPress environment and removes local files. Options: - --debug Enable debug output. [boolean] [default: false] + --debug Enable debug output. [boolean] [default: false] + --scripts Execute any configured lifecycle scripts. [boolean] [default: true] ``` ### `wp-env logs [environment]` @@ -557,19 +558,16 @@ These can be overridden by setting a value within the `config` configuration. Se Additionally, the values referencing a URL include the specified port for the given environment. So if you set `testsPort: 3000, port: 2000`, `WP_HOME` (for example) will be `http://localhost:3000` on the tests instance and `http://localhost:2000` on the development instance. -## Lifecycle Hooks - -These hooks are executed at certain points during the lifecycle of a command's execution. Keep in mind that these will be executed on both fresh and existing -environments, so, ensure any commands you build won't break on subsequent executions. +## Lifecycle Scripts -### After Setup +Using the `lifecycleScripts` option in `.wp-env.json` will allow you to set arbitrary commands to be executed at certain points in the lifecycle. This configuration +can also be overridden using `WP_ENV_LIFECYCLE_SCRIPT_{LIFECYCLE_EVENT}` environment variables, with the remainder being the all-caps snake_case name of the option, for +example, `WP_ENV_LIFECYCLE_SCRIPT_AFTER_START`. Keep in mind that these will be executed on both fresh and existing environments, so, ensure any commands you +build won't break on subsequent executions. -Using the `afterSetup` option in `.wp-env.json` files will allow you to configure an arbitrary command to execute after the environment's setup is complete: - -- `wp-env start`: Runs when the config changes, WordPress updates, or you pass the `--update` flag. -- `wp-env clean`: Runs after the selected environments have been cleaned. - -You can override the `afterSetup` option using the `WP_ENV_AFTER_SETUP` environment variable. +* `afterStart`: Runs after `wp-env start` has finished setting up the environment. +* `afterClean`: Runs after `wp-env clean` has finished cleaning the environment. +* `afterDestroy`: Runs after `wp-env destroy` has destroyed the environment. ## Examples @@ -705,7 +703,9 @@ This is useful for performing some actions after setting up the environment, suc ```json { - "afterSetup": "node tests/e2e/bin/setup-env.js" + "lifecycleScripts": { + "afterStart": "node tests/e2e/bin/setup-env.js" + } } ``` diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index dae7a46fecd35..72a5eec911e08 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -45,7 +45,7 @@ const withSpinner = ( error ) => { if ( error instanceof env.ValidationError || - error instanceof env.AfterSetupError + error instanceof env.LifecycleScriptError ) { // Error is a configuration error. That means the user did something wrong. spinner.fail( error.message ); @@ -233,7 +233,13 @@ module.exports = function cli() { wpRed( 'Destroy the WordPress environment. Deletes docker containers, volumes, and networks associated with the WordPress environment and removes local files.' ), - () => {}, + ( args ) => { + args.option( 'scripts', { + type: 'boolean', + describe: 'Execute any configured lifecycle scripts.', + default: true, + } ); + }, withSpinner( env.destroy ) ); yargs.command( diff --git a/packages/env/lib/commands/clean.js b/packages/env/lib/commands/clean.js index 20b7b1550c704..e3977b3b63b8c 100644 --- a/packages/env/lib/commands/clean.js +++ b/packages/env/lib/commands/clean.js @@ -9,7 +9,7 @@ const dockerCompose = require( 'docker-compose' ); */ const initConfig = require( '../init-config' ); const { configureWordPress, resetDatabase } = require( '../wordpress' ); -const { executeAfterSetup } = require( '../execute-after-setup' ); +const { executeLifecycleScript } = require( '../execute-lifecycle-script' ); /** * @typedef {import('../wordpress').WPEnvironment} WPEnvironment @@ -65,9 +65,8 @@ module.exports = async function clean( { await Promise.all( tasks ); - // Execute any configured command that should run after the environment has finished being set up. if ( scripts ) { - executeAfterSetup( config, spinner ); + await executeLifecycleScript( 'afterClean', config, spinner ); } spinner.text = `Cleaned ${ description }.`; diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index 4d8a7e4ee6d6b..fbbff0c8a2898 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -17,21 +17,21 @@ const rimraf = util.promisify( require( 'rimraf' ) ); * Internal dependencies */ const { loadConfig } = require( '../config' ); +const { executeLifecycleScript } = require( '../execute-lifecycle-script' ); /** * Destroy the development server. * * @param {Object} options * @param {Object} options.spinner A CLI spinner which indicates progress. + * @param {boolean} options.scripts Indicates whether or not lifecycle scripts should be executed. * @param {boolean} options.debug True if debug mode is enabled. */ -module.exports = async function destroy( { spinner, debug } ) { - const { dockerComposeConfigPath, workDirectoryPath } = await loadConfig( - path.resolve( '.' ) - ); +module.exports = async function destroy( { spinner, scripts, debug } ) { + const config = await loadConfig( path.resolve( '.' ) ); try { - await fs.readdir( workDirectoryPath ); + await fs.readdir( config.workDirectoryPath ); } catch { spinner.text = 'Could not find any files to remove.'; return; @@ -60,7 +60,7 @@ module.exports = async function destroy( { spinner, debug } ) { spinner.text = 'Removing docker images, volumes, and networks.'; await dockerCompose.down( { - config: dockerComposeConfigPath, + config: config.dockerComposeConfigPath, commandOptions: [ '--volumes', '--remove-orphans', '--rmi', 'all' ], log: debug, } ); @@ -70,7 +70,11 @@ module.exports = async function destroy( { spinner, debug } ) { // by this point, which causes rimraf to fail. We need to wait at least 2.5-5s, // but using 10s in case it's dependant on the machine. await new Promise( ( resolve ) => setTimeout( resolve, 10000 ) ); - await rimraf( workDirectoryPath ); + await rimraf( config.workDirectoryPath ); + + if ( scripts ) { + await executeLifecycleScript( 'afterDestroy', config, spinner ); + } spinner.text = 'Removed WordPress environment.'; }; diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index 474d8fefbdaf0..fed05e4f3d16f 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -31,7 +31,7 @@ const { } = require( '../wordpress' ); const { didCacheChange, setCache } = require( '../cache' ); const md5 = require( '../md5' ); -const { executeAfterSetup } = require( '../execute-after-setup' ); +const { executeLifecycleScript } = require( '../execute-lifecycle-script' ); /** * @typedef {import('../config').WPConfig} WPConfig @@ -203,17 +203,16 @@ module.exports = async function start( { } ), ] ); - // Execute any configured command that should run after the environment has finished being set up. - if ( scripts ) { - executeAfterSetup( config, spinner ); - } - // Set the cache key once everything has been configured. await setCache( CONFIG_CACHE_KEY, configHash, { workDirectoryPath, } ); } + if ( scripts ) { + await executeLifecycleScript( 'afterStart', config, spinner ); + } + const siteUrl = config.env.development.config.WP_SITEURL; const testsSiteUrl = config.env.tests.config.WP_SITEURL; diff --git a/packages/env/lib/config/get-config-from-environment-vars.js b/packages/env/lib/config/get-config-from-environment-vars.js index 1447d1a4de271..8a4608b859b7f 100644 --- a/packages/env/lib/config/get-config-from-environment-vars.js +++ b/packages/env/lib/config/get-config-from-environment-vars.js @@ -16,10 +16,11 @@ const { checkPort, checkVersion, checkString } = require( './validate-config' ); * Environment variable configuration. * * @typedef WPEnvironmentVariableConfig - * @property {?number} port An override for the development environment's port. - * @property {?number} testsPort An override for the testing environment's port. - * @property {?WPSource} coreSource An override for all environment's coreSource. - * @property {?string} phpVersion An override for all environment's PHP version. + * @property {?number} port An override for the development environment's port. + * @property {?number} testsPort An override for the testing environment's port. + * @property {?WPSource} coreSource An override for all environment's coreSource. + * @property {?string} phpVersion An override for all environment's PHP version. + * @property {?Object.} lifecycleScripts An override for various lifecycle scripts. */ /** @@ -33,6 +34,7 @@ module.exports = function getConfigFromEnvironmentVars( cacheDirectoryPath ) { const environmentConfig = { port: getPortFromEnvironmentVariable( 'WP_ENV_PORT' ), testsPort: getPortFromEnvironmentVariable( 'WP_ENV_TESTS_PORT' ), + lifecycleScripts: getLifecycleScriptOverrides(), }; if ( process.env.WP_ENV_CORE ) { @@ -53,15 +55,6 @@ module.exports = function getConfigFromEnvironmentVars( cacheDirectoryPath ) { environmentConfig.phpVersion = process.env.WP_ENV_PHP_VERSION; } - if ( process.env.WP_ENV_AFTER_SETUP ) { - checkString( - 'environment variable', - 'WP_ENV_AFTER_SETUP', - process.env.WP_ENV_AFTER_SETUP - ); - environmentConfig.afterSetup = process.env.WP_ENV_AFTER_SETUP; - } - return environmentConfig; }; @@ -70,7 +63,7 @@ module.exports = function getConfigFromEnvironmentVars( cacheDirectoryPath ) { * * @param {string} varName The environment variable to check (e.g. WP_ENV_PORT). * - * @return {number} The parsed port number + * @return {number} The parsed port number. */ function getPortFromEnvironmentVariable( varName ) { if ( ! process.env[ varName ] ) { @@ -84,3 +77,30 @@ function getPortFromEnvironmentVariable( varName ) { return port; } + +/** + * Parses the lifecycle script environment variables. + * + * @return {Object.} The parsed lifecycle scripts. + */ +function getLifecycleScriptOverrides() { + const lifecycleScripts = {}; + + // Find all of the lifecycle script overrides and parse them. + const lifecycleEnvironmentVars = { + WP_ENV_LIFECYCLE_SCRIPT_AFTER_START: 'afterStart', + WP_ENV_LIFECYCLE_SCRIPT_AFTER_CLEAN: 'afterClean', + WP_ENV_LIFECYCLE_SCRIPT_AFTER_DESTROY: 'afterDestroy', + }; + for ( const envVar in lifecycleEnvironmentVars ) { + const scriptValue = process.env[ envVar ]; + if ( scriptValue === undefined ) { + continue; + } + + checkString( 'environment variable', envVar, scriptValue ); + lifecycleScripts[ lifecycleEnvironmentVars[ envVar ] ] = scriptValue; + } + + return lifecycleScripts; +} diff --git a/packages/env/lib/config/load-config.js b/packages/env/lib/config/load-config.js index 1459dbe5a4e62..f8a61d278c8a0 100644 --- a/packages/env/lib/config/load-config.js +++ b/packages/env/lib/config/load-config.js @@ -27,7 +27,7 @@ const postProcessConfig = require( './post-process-config' ); * @property {string} workDirectoryPath Path to the work directory located in ~/.wp-env. * @property {string} dockerComposeConfigPath Path to the docker-compose.yml file. * @property {boolean} detectedLocalConfig If true, wp-env detected local config and used it. - * @property {string} afterSetup The command(s) to run after configuring WordPress on start and clean. + * @property {Object.} lifecycleScripts Any lifecycle scripts that we might need to execute. * @property {Object.} env Specific config for different environments. * @property {boolean} debug True if debug mode is enabled. */ @@ -68,7 +68,7 @@ module.exports = async function loadConfig( configDirectoryPath ) { configFilePath, getConfigFilePath( configDirectoryPath, 'override' ), ] ), - afterSetup: config.afterSetup, + lifecycleScripts: config.lifecycleScripts, env: config.env, }; }; diff --git a/packages/env/lib/config/merge-configs.js b/packages/env/lib/config/merge-configs.js index 4494cea2bfb15..bccfcc4da3e0d 100644 --- a/packages/env/lib/config/merge-configs.js +++ b/packages/env/lib/config/merge-configs.js @@ -45,7 +45,8 @@ function mergeConfig( config, toMerge ) { switch ( option ) { // Some config options are merged together instead of entirely replaced. case 'config': - case 'mappings': { + case 'mappings': + case 'lifecycleScripts': { config[ option ] = Object.assign( config[ option ], toMerge[ option ] diff --git a/packages/env/lib/config/parse-config.js b/packages/env/lib/config/parse-config.js index 1181f2f9e6bb3..064ec368c31c1 100644 --- a/packages/env/lib/config/parse-config.js +++ b/packages/env/lib/config/parse-config.js @@ -14,7 +14,6 @@ const { } = require( './parse-source-string' ); const { ValidationError, - checkString, checkPort, checkStringArray, checkObjectWithValues, @@ -34,10 +33,13 @@ const mergeConfigs = require( './merge-configs' ); * The root configuration options. * * @typedef WPRootConfigOptions - * @property {number} port The port to use in the development environment. - * @property {number} testsPort The port to use in the tests environment. - * @property {string|null} afterSetup The command(s) to run after configuring WordPress on start and clean. - * @property {Object.} env The environment-specific configuration options. + * @property {number} port The port to use in the development environment. + * @property {number} testsPort The port to use in the tests environment. + * @property {Object.} lifecycleScripts The scripts to run at certain points in the command lifecycle. + * @property {Object.} lifecycleScripts.afterStart The script to run after the "start" command has completed. + * @property {Object.} lifecycleScripts.afterClean The script to run after the "clean" command has completed. + * @property {Object.} lifecycleScripts.afterDestroy The script to run after the "destroy" command has completed. + * @property {Object.} env The environment-specific configuration options. */ /** @@ -223,7 +225,11 @@ async function getDefaultConfig( // These configuration options are root-only and should not be present // on environment-specific configuration objects. - afterSetup: null, + lifecycleScripts: { + afterStart: null, + afterClean: null, + afterDestroy: null, + }, env: { development: {}, tests: { @@ -250,6 +256,7 @@ function getEnvironmentVarOverrides( cacheDirectoryPath ) { // Create a service config object so we can merge it with the others // and override anything that the configuration options need to. const overrideConfig = { + lifecycleScripts: overrides.lifecycleScripts, env: { development: {}, tests: {}, @@ -282,10 +289,6 @@ function getEnvironmentVarOverrides( cacheDirectoryPath ) { overrideConfig.env.tests.phpVersion = overrides.phpVersion; } - if ( overrides.afterSetup ) { - overrideConfig.afterSetup = overrides.afterSetup; - } - return overrideConfig; } @@ -333,18 +336,28 @@ async function parseRootConfig( configFile, rawConfig, options ) { checkPort( configFile, `testsPort`, rawConfig.testsPort ); parsedConfig.testsPort = rawConfig.testsPort; } - if ( rawConfig.afterSetup !== undefined ) { - // Support null as a valid input. - if ( rawConfig.afterSetup !== null ) { - checkString( configFile, 'afterSetup', rawConfig.afterSetup ); - } - parsedConfig.afterSetup = rawConfig.afterSetup; + parsedConfig.lifecycleScripts = {}; + if ( rawConfig.lifecycleScripts ) { + checkObjectWithValues( + configFile, + 'lifecycleScripts', + rawConfig.lifecycleScripts, + [ 'null', 'string' ], + true + ); + parsedConfig.lifecycleScripts = rawConfig.lifecycleScripts; } // Parse the environment-specific configs so they're accessible to the root. parsedConfig.env = {}; if ( rawConfig.env ) { - checkObjectWithValues( configFile, 'env', rawConfig.env, [ 'object' ] ); + checkObjectWithValues( + configFile, + 'env', + rawConfig.env, + [ 'object' ], + false + ); for ( const env in rawConfig.env ) { parsedConfig.env[ env ] = await parseEnvironmentConfig( configFile, @@ -395,7 +408,7 @@ async function parseEnvironmentConfig( // configuration options that we will parse. switch ( key ) { case 'testsPort': - case 'afterSetup': + case 'lifecycleScripts': case 'env': { if ( options.rootConfig ) { continue; @@ -465,7 +478,8 @@ async function parseEnvironmentConfig( configFile, `${ environmentPrefix }config`, config.config, - [ 'string', 'number', 'boolean', 'empty' ] + [ 'string', 'number', 'boolean' ], + true ); parsedConfig.config = config.config; @@ -490,7 +504,8 @@ async function parseEnvironmentConfig( configFile, `${ environmentPrefix }mappings`, config.mappings, - [ 'string' ] + [ 'string' ], + false ); parsedConfig.mappings = Object.entries( config.mappings ).reduce( ( result, [ wpDir, localDir ] ) => { diff --git a/packages/env/lib/config/post-process-config.js b/packages/env/lib/config/post-process-config.js index 46723b9f3d8c3..d09843893ea69 100644 --- a/packages/env/lib/config/post-process-config.js +++ b/packages/env/lib/config/post-process-config.js @@ -59,9 +59,9 @@ function mergeRootToEnvironments( config ) { config.env.tests.port = config.testsPort; delete config.testsPort; } - if ( config.afterSetup !== undefined ) { - removedRootOptions.afterSetup = config.afterSetup; - delete config.afterSetup; + if ( config.lifecycleScripts !== undefined ) { + removedRootOptions.lifecycleScripts = config.lifecycleScripts; + delete config.lifecycleScripts; } // Merge the root config and the environment configs together so that diff --git a/packages/env/lib/config/test/__snapshots__/config-integration.js.snap b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap index 914b0ec7cc0a4..d9d0883589aff 100644 --- a/packages/env/lib/config/test/__snapshots__/config-integration.js.snap +++ b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap @@ -2,7 +2,6 @@ exports[`Config Integration should load local and override configuration files 1`] = ` { - "afterSetup": null, "configDirectoryPath": "/test/gutenberg", "detectedLocalConfig": true, "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", @@ -62,6 +61,11 @@ exports[`Config Integration should load local and override configuration files 1 "themeSources": [], }, }, + "lifecycleScripts": { + "afterClean": null, + "afterDestroy": "test", + "afterStart": null, + }, "name": "gutenberg", "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", } @@ -69,7 +73,6 @@ exports[`Config Integration should load local and override configuration files 1 exports[`Config Integration should load local configuration file 1`] = ` { - "afterSetup": "test", "configDirectoryPath": "/test/gutenberg", "detectedLocalConfig": true, "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", @@ -129,6 +132,11 @@ exports[`Config Integration should load local configuration file 1`] = ` "themeSources": [], }, }, + "lifecycleScripts": { + "afterClean": null, + "afterDestroy": null, + "afterStart": "test", + }, "name": "gutenberg", "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", } @@ -136,7 +144,6 @@ exports[`Config Integration should load local configuration file 1`] = ` exports[`Config Integration should use default configuration 1`] = ` { - "afterSetup": null, "configDirectoryPath": "/test/gutenberg", "detectedLocalConfig": true, "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", @@ -196,6 +203,11 @@ exports[`Config Integration should use default configuration 1`] = ` "themeSources": [], }, }, + "lifecycleScripts": { + "afterClean": null, + "afterDestroy": null, + "afterStart": null, + }, "name": "gutenberg", "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", } @@ -203,7 +215,6 @@ exports[`Config Integration should use default configuration 1`] = ` exports[`Config Integration should use environment variables over local and override configuration files 1`] = ` { - "afterSetup": "test", "configDirectoryPath": "/test/gutenberg", "detectedLocalConfig": true, "dockerComposeConfigPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338/docker-compose.yml", @@ -265,6 +276,11 @@ exports[`Config Integration should use environment variables over local and over "themeSources": [], }, }, + "lifecycleScripts": { + "afterClean": null, + "afterDestroy": null, + "afterStart": "test", + }, "name": "gutenberg", "workDirectoryPath": "/cache/5fea4c5689ef6cc4a4e6eaaa39323338", } diff --git a/packages/env/lib/config/test/config-integration.js b/packages/env/lib/config/test/config-integration.js index 08c3277f06a45..e9835e0c0ce8e 100644 --- a/packages/env/lib/config/test/config-integration.js +++ b/packages/env/lib/config/test/config-integration.js @@ -48,7 +48,7 @@ describe( 'Config Integration', () => { delete process.env.WP_ENV_HOME; delete process.env.WP_ENV_PORT; delete process.env.WP_ENV_TESTS_PORT; - delete process.env.WP_ENV_AFTER_SETUP; + delete process.env.WP_ENV_LIFECYCLE_SCRIPT_AFTER_START; } ); it( 'should use default configuration', async () => { @@ -69,7 +69,11 @@ describe( 'Config Integration', () => { return JSON.stringify( { core: 'WordPress/WordPress#trunk', port: 123, - afterSetup: 'test', + lifecycleScripts: { + afterStart: 'test', + afterClean: null, + afterDestroy: null, + }, } ); } @@ -90,12 +94,22 @@ describe( 'Config Integration', () => { core: 'WordPress/WordPress#trunk', port: 123, testsPort: 456, + lifecycleScripts: { + afterStart: 'test', + afterClean: null, + afterDestroy: null, + }, } ); } if ( fileName === '/test/gutenberg/.wp-env.override.json' ) { return JSON.stringify( { port: 999, + lifecycleScripts: { + afterStart: null, + afterClean: null, + afterDestroy: 'test', + }, } ); } @@ -112,7 +126,7 @@ describe( 'Config Integration', () => { it( 'should use environment variables over local and override configuration files', async () => { process.env.WP_ENV_PORT = 12345; process.env.WP_ENV_TESTS_PORT = 61234; - process.env.WP_ENV_AFTER_SETUP = 'test'; + process.env.WP_ENV_LIFECYCLE_SCRIPT_AFTER_START = 'test'; readFile.mockImplementation( async ( fileName ) => { if ( fileName === '/test/gutenberg/.wp-env.json' ) { @@ -120,7 +134,11 @@ describe( 'Config Integration', () => { core: 'WordPress/WordPress#trunk', port: 123, testsPort: 456, - afterSetup: 'local', + lifecycleScripts: { + afterStart: 'local', + afterClean: null, + afterDestroy: null, + }, } ); } @@ -137,6 +155,10 @@ describe( 'Config Integration', () => { expect( config.env.development.port ).toEqual( 12345 ); expect( config.env.tests.port ).toEqual( 61234 ); + expect( config.lifecycleScripts ).toHaveProperty( + 'afterStart', + 'test' + ); expect( config ).toMatchSnapshot(); } ); } ); diff --git a/packages/env/lib/config/test/merge-configs.js b/packages/env/lib/config/test/merge-configs.js index d3769b9d8ff2b..cc3a0cbecf5a3 100644 --- a/packages/env/lib/config/test/merge-configs.js +++ b/packages/env/lib/config/test/merge-configs.js @@ -16,12 +16,18 @@ describe( 'mergeConfigs', () => { config: { WP_TEST: 'test', }, + lifecycleScripts: { + afterStart: 'test', + }, }, { port: 8889, config: { WP_TEST_2: 'test-2', }, + lifecycleScripts: { + afterDestroy: 'test-2', + }, } ); @@ -35,6 +41,10 @@ describe( 'mergeConfigs', () => { WP_TEST: 'test', WP_TEST_2: 'test-2', }, + lifecycleScripts: { + afterStart: 'test', + afterDestroy: 'test-2', + }, } ); } ); diff --git a/packages/env/lib/config/test/parse-config.js b/packages/env/lib/config/test/parse-config.js index 7edb5a75d6f7c..5052199a983d5 100644 --- a/packages/env/lib/config/test/parse-config.js +++ b/packages/env/lib/config/test/parse-config.js @@ -51,7 +51,11 @@ const DEFAULT_CONFIG = { WP_HOME: 'http://localhost', }, mappings: {}, - afterSetup: null, + lifecycleScripts: { + afterStart: null, + afterClean: null, + afterDestroy: null, + }, env: { development: {}, tests: { @@ -76,7 +80,7 @@ describe( 'parseConfig', () => { delete process.env.WP_ENV_TESTS_PORT; delete process.env.WP_ENV_CORE; delete process.env.WP_ENV_PHP_VERSION; - delete process.env.WP_ENV_AFTER_SETUP; + delete process.env.WP_ENV_LIFECYCLE_SCRIPT_AFTER_START; } ); it( 'should return default config', async () => { @@ -141,7 +145,9 @@ describe( 'parseConfig', () => { return { core: 'WordPress/WordPress#Test', phpVersion: '1.0', - afterSetup: 'test', + lifecycleScripts: { + afterStart: 'test', + }, env: { development: { port: 1234, @@ -156,6 +162,9 @@ describe( 'parseConfig', () => { if ( configFile === path.resolve( './.wp-env.override.json' ) ) { return { phpVersion: '2.0', + lifecycleScripts: { + afterDestroy: 'test', + }, env: { tests: { port: 1011, @@ -181,7 +190,11 @@ describe( 'parseConfig', () => { type: 'git', }, phpVersion: '2.0', - afterSetup: 'test', + lifecycleScripts: { + ...DEFAULT_CONFIG.lifecycleScripts, + afterStart: 'test', + afterDestroy: 'test', + }, env: { development: { ...DEFAULT_CONFIG.env.development, @@ -270,7 +283,7 @@ describe( 'parseConfig', () => { process.env.WP_ENV_TESTS_PORT = 456; process.env.WP_ENV_CORE = 'WordPress/WordPress#test'; process.env.WP_ENV_PHP_VERSION = '3.0'; - process.env.WP_ENV_AFTER_SETUP = 'test after'; + process.env.WP_ENV_LIFECYCLE_SCRIPT_AFTER_START = 'test after'; const parsed = await parseConfig( './', '/cache' ); @@ -288,7 +301,10 @@ describe( 'parseConfig', () => { type: 'git', }, phpVersion: '3.0', - afterSetup: 'test after', + lifecycleScripts: { + ...DEFAULT_CONFIG.lifecycleScripts, + afterStart: 'test after', + }, env: { development: { port: 123, diff --git a/packages/env/lib/config/test/post-process-config.js b/packages/env/lib/config/test/post-process-config.js index 8559728b969d1..c64a98c7b2ad0 100644 --- a/packages/env/lib/config/test/post-process-config.js +++ b/packages/env/lib/config/test/post-process-config.js @@ -156,7 +156,9 @@ describe( 'postProcessConfig', () => { const processed = postProcessConfig( { port: 8888, testsPort: 8889, - afterSetup: 'test', + lifecycleScripts: { + afterStart: 'test', + }, env: { development: {}, tests: {}, @@ -166,7 +168,9 @@ describe( 'postProcessConfig', () => { expect( processed ).toEqual( { port: 8888, testsPort: 8889, - afterSetup: 'test', + lifecycleScripts: { + afterStart: 'test', + }, env: { development: { port: 8888, diff --git a/packages/env/lib/config/test/validate-config.js b/packages/env/lib/config/test/validate-config.js index adcae2b90e241..bb1decfd53dfb 100644 --- a/packages/env/lib/config/test/validate-config.js +++ b/packages/env/lib/config/test/validate-config.js @@ -117,7 +117,7 @@ describe( 'validate-config', () => { describe( 'checkObjectWithValues', () => { it( 'throws when not an object', () => { expect( () => - checkObjectWithValues( 'test.json', 'test', 'test', [] ) + checkObjectWithValues( 'test.json', 'test', 'test', [], false ) ).toThrow( new ValidationError( 'Invalid test.json: "test" must be an object.' @@ -125,7 +125,13 @@ describe( 'validate-config', () => { ); expect( () => - checkObjectWithValues( 'test.json', 'test', [ 'test' ], [] ) + checkObjectWithValues( + 'test.json', + 'test', + [ 'test' ], + [], + false + ) ).toThrow( new ValidationError( 'Invalid test.json: "test" must be an object.' @@ -139,33 +145,42 @@ describe( 'validate-config', () => { 'test.json', 'test', { test: 'test' }, - [] + [], + false ) ).toThrow( new ValidationError( - 'Invalid test.json: "test.test" must be a .' + 'Invalid test.json: "test.test" must be of type: .' ) ); } ); it( 'throws when type is not allowed', () => { expect( () => - checkObjectWithValues( 'test.json', 'test', { test: 'test' }, [ - 'number', - ] ) + checkObjectWithValues( + 'test.json', + 'test', + { test: 'test' }, + [ 'number' ], + false + ) ).toThrow( new ValidationError( - 'Invalid test.json: "test.test" must be a number.' + 'Invalid test.json: "test.test" must be of type: number.' ) ); expect( () => - checkObjectWithValues( 'test.json', 'test', { test: 1 }, [ - 'string', - ] ) + checkObjectWithValues( + 'test.json', + 'test', + { test: 1 }, + [ 'string' ], + false + ) ).toThrow( new ValidationError( - 'Invalid test.json: "test.test" must be a string.' + 'Invalid test.json: "test.test" must be of type: string.' ) ); @@ -174,32 +189,65 @@ describe( 'validate-config', () => { 'test.json', 'test', { test: [ 'test' ] }, - [ 'object', 'string' ] + [ 'object', 'string' ], + false + ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test.test" must be of type: object or string.' + ) + ); + + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: null }, + [ 'object' ], + true ) ).toThrow( new ValidationError( - 'Invalid test.json: "test.test" must be a object or string.' + 'Invalid test.json: "test.test" must be of type: object.' ) ); } ); it( 'passes when type is allowed', () => { expect( () => - checkObjectWithValues( 'test.json', 'test', { test: 'test' }, [ - 'string', - ] ) + checkObjectWithValues( + 'test.json', + 'test', + { test: 'test' }, + [ 'string' ], + false + ) + ).not.toThrow(); + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: '' }, + [ 'string' ], + true + ) ).not.toThrow(); expect( () => - checkObjectWithValues( 'test.json', 'test', { test: 1 }, [ - 'number', - ] ) + checkObjectWithValues( + 'test.json', + 'test', + { test: 1 }, + [ 'number' ], + false + ) ).not.toThrow(); expect( () => checkObjectWithValues( 'test.json', 'test', { test: { nested: 'test' } }, - [ 'object' ] + [ 'object' ], + false ) ).not.toThrow(); expect( () => @@ -207,7 +255,17 @@ describe( 'validate-config', () => { 'test.json', 'test', { test: [ 'test' ] }, - [ 'array' ] + [ 'array' ], + false + ) + ).not.toThrow(); + expect( () => + checkObjectWithValues( + 'test.json', + 'test', + { test: null }, + [ 'null' ], + false ) ).not.toThrow(); } ); diff --git a/packages/env/lib/config/validate-config.js b/packages/env/lib/config/validate-config.js index f5c5d61f297c6..4aa62cb457155 100644 --- a/packages/env/lib/config/validate-config.js +++ b/packages/env/lib/config/validate-config.js @@ -74,8 +74,15 @@ function checkStringArray( configFile, configKey, array ) { * @param {string} configKey The configuration key we're validating. * @param {string[]} obj The object that we're checking. * @param {string[]} allowTypes The types that are allowed. + * @param {boolean} allowEmpty Indicates whether or not empty values are allowed. */ -function checkObjectWithValues( configFile, configKey, obj, allowTypes ) { +function checkObjectWithValues( + configFile, + configKey, + obj, + allowTypes, + allowEmpty +) { if ( allowTypes === undefined ) { allowTypes = []; } @@ -87,18 +94,37 @@ function checkObjectWithValues( configFile, configKey, obj, allowTypes ) { } for ( const key in obj ) { - if ( ! obj[ key ] && ! allowTypes.includes( 'empty' ) ) { - throw new ValidationError( - `Invalid ${ configFile }: "${ configKey }.${ key }" must not be empty.` - ); + // Some values need to be uniquely validated. + switch ( obj[ key ] ) { + case null: + case undefined: { + break; + } + + default: { + if ( ! obj[ key ] && ! allowEmpty ) { + throw new ValidationError( + `Invalid ${ configFile }: "${ configKey }.${ key }" must not be empty.` + ); + } + } } - // Identify arrays uniquely. - const type = Array.isArray( obj[ key ] ) ? 'array' : typeof obj[ key ]; + // Some types need to be uniquely identified. + let type; + if ( obj[ key ] === undefined ) { + type = 'undefined'; + } else if ( obj[ key ] === null ) { + type = 'null'; + } else if ( Array.isArray( obj[ key ] ) ) { + type = 'array'; + } else { + type = typeof obj[ key ]; + } if ( ! allowTypes.includes( type ) ) { throw new ValidationError( - `Invalid ${ configFile }: "${ configKey }.${ key }" must be a ${ allowTypes.join( + `Invalid ${ configFile }: "${ configKey }.${ key }" must be of type: ${ allowTypes.join( ' or ' ) }.` ); diff --git a/packages/env/lib/env.js b/packages/env/lib/env.js index be093afe5a106..b183ea304a496 100644 --- a/packages/env/lib/env.js +++ b/packages/env/lib/env.js @@ -3,11 +3,11 @@ * Internal dependencies */ const { ValidationError } = require( './config' ); -const { AfterSetupError } = require( './execute-after-setup' ); +const { LifecycleScriptError } = require( './execute-lifecycle-script' ); const commands = require( './commands' ); module.exports = { ...commands, ValidationError, - AfterSetupError, + LifecycleScriptError, }; diff --git a/packages/env/lib/execute-after-setup.js b/packages/env/lib/execute-after-setup.js deleted file mode 100644 index 09b642d2fef56..0000000000000 --- a/packages/env/lib/execute-after-setup.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; -/** - * External dependencies - */ -const { execSync } = require( 'child_process' ); - -/** - * @typedef {import('./config').WPConfig} WPConfig - */ - -/** - * Error subtype which indicates that the afterSetup command failed. - */ -class AfterSetupError extends Error {} - -/** - * Executes any defined afterSetup command. - * - * @param {WPConfig} config The config object to use. - * @param {Object} spinner A CLI spinner which indciates progress. - */ -function executeAfterSetup( config, spinner ) { - if ( ! config.afterSetup ) { - return; - } - - spinner.text = 'Executing Script: afterSetup'; - - try { - let output = execSync( config.afterSetup, { - encoding: 'utf-8', - stdio: 'pipe', - env: process.env, - } ); - - // Remove any trailing whitespace for nicer output. - output = output.trimRight(); - - // We don't need to bother with any output if there isn't any. - if ( output ) { - spinner.info( `After Setup:\n${ output }` ); - } - } catch ( error ) { - throw new AfterSetupError( `After Setup:\n${ error.stderr }` ); - } -} - -module.exports = { - AfterSetupError, - executeAfterSetup, -}; diff --git a/packages/env/lib/execute-lifecycle-script.js b/packages/env/lib/execute-lifecycle-script.js new file mode 100644 index 0000000000000..388d63851948b --- /dev/null +++ b/packages/env/lib/execute-lifecycle-script.js @@ -0,0 +1,86 @@ +'use strict'; +/** + * External dependencies + */ +const { exec } = require( 'child_process' ); + +/** + * @typedef {import('./config').WPConfig} WPConfig + */ + +/** + * Error subtype which indicates that the lifecycle script failed. + */ +class LifecycleScriptError extends Error { + constructor( event, stderr ) { + super( `${ event } Error:\n${ stderr }` ); + + this.event = event; + } +} + +/** + * Executes any defined life cycle script. + * + * @param {string} event The lifecycle event to run the script for. + * @param {WPConfig} config The config object to use. + * @param {Object} spinner A CLI spinner which indciates progress. + * + * @return {Promise} Resolves when the script has completed and rejects when there is an error. + */ +function executeLifecycleScript( event, config, spinner ) { + if ( ! config.lifecycleScripts[ event ] ) { + return Promise.resolve(); + } + + return new Promise( ( resolve, reject ) => { + // We're going to append the script output to the spinner while it's executing. + const spinnerMessage = `Executing ${ event } Script`; + spinner.text = spinnerMessage; + + // Execute the script asynchronously so that it won't block the spinner. + const childProc = exec( config.lifecycleScripts[ event ], { + encoding: 'utf-8', + stdio: 'pipe', + env: process.env, + } ); + + // Collect all of the output so that we can make use of it. + let output = ''; + childProc.stdout.on( 'data', ( data ) => { + output += data; + + // Keep the spinner updated with the command output. + spinner.text = `${ spinnerMessage }\n${ output }`; + } ); + + // Listen for any error output so we can display it if the command fails. + let error = ''; + childProc.stderr.on( 'data', ( data ) => { + error += data; + } ); + + // Pass any process creation errors directly up. + childProc.on( 'error', reject ); + + // Handle the completion of the command based on whether it was successful or not. + childProc.on( 'exit', ( code ) => { + if ( code === 0 ) { + // Keep the output of the command in debug mode. + if ( config.debug ) { + spinner.info( `${ event } Script:\n${ output.trimEnd() }` ); + } + + resolve(); + return; + } + + reject( new LifecycleScriptError( event, error.trimEnd() ) ); + } ); + } ); +} + +module.exports = { + LifecycleScriptError, + executeLifecycleScript, +}; diff --git a/packages/env/lib/test/cli.js b/packages/env/lib/test/cli.js index 336d254493682..ba850e3259f4c 100644 --- a/packages/env/lib/test/cli.js +++ b/packages/env/lib/test/cli.js @@ -22,7 +22,7 @@ jest.mock( '../env', () => { clean: jest.fn( Promise.resolve.bind( Promise ) ), run: jest.fn( Promise.resolve.bind( Promise ) ), ValidationError: actual.ValidationError, - AfterSetupError: actual.AfterSetupError, + LifecycleScriptError: actual.LifecycleScriptError, }; } ); diff --git a/packages/env/lib/test/execute-after-setup.js b/packages/env/lib/test/execute-after-setup.js deleted file mode 100644 index 4ea0e8fd9bfdd..0000000000000 --- a/packages/env/lib/test/execute-after-setup.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; -/** - * External dependencies - */ -const { execSync } = require( 'child_process' ); - -/** - * Internal dependencies - */ -const { - AfterSetupError, - executeAfterSetup, -} = require( '../execute-after-setup' ); - -jest.mock( 'child_process', () => ( { - execSync: jest.fn(), -} ) ); - -describe( 'executeAfterSetup', () => { - const spinner = { - info: jest.fn(), - }; - - afterEach( () => { - jest.clearAllMocks(); - } ); - - it( 'should do nothing without afterSetup option', () => { - executeAfterSetup( { afterSetup: null }, spinner ); - - expect( spinner.info ).not.toHaveBeenCalled(); - } ); - - it( 'should run afterSetup option and print output without extra whitespace', () => { - execSync.mockReturnValue( 'Test \n' ); - - executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ); - - expect( execSync ).toHaveBeenCalled(); - expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test Setup' ); - expect( spinner.info ).toHaveBeenCalledWith( 'After Setup:\nTest' ); - } ); - - it( 'should print nothing if afterSetup returns no output', () => { - execSync.mockReturnValue( '' ); - - executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ); - - expect( execSync ).toHaveBeenCalled(); - expect( execSync.mock.calls[ 0 ][ 0 ] ).toEqual( 'Test Setup' ); - expect( spinner.info ).not.toHaveBeenCalled(); - } ); - - it( 'should throw AfterSetupError when process errors', () => { - execSync.mockImplementation( ( command ) => { - expect( command ).toEqual( 'Test Setup' ); - throw { stderr: 'Something bad happened.' }; - } ); - - expect( () => - executeAfterSetup( { afterSetup: 'Test Setup' }, spinner ) - ).toThrow( - new AfterSetupError( 'After Setup:\nSomething bad happened.' ) - ); - } ); -} ); diff --git a/packages/env/lib/test/execute-lifecycle-script.js b/packages/env/lib/test/execute-lifecycle-script.js new file mode 100644 index 0000000000000..d4e81c7fff519 --- /dev/null +++ b/packages/env/lib/test/execute-lifecycle-script.js @@ -0,0 +1,59 @@ +'use strict'; +/* eslint-disable jest/no-conditional-expect */ +/** + * Internal dependencies + */ +const { + LifecycleScriptError, + executeLifecycleScript, +} = require( '../execute-lifecycle-script' ); + +describe( 'executeLifecycleScript', () => { + const spinner = { + info: jest.fn(), + }; + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should do nothing without event option when debugging', async () => { + await executeLifecycleScript( + 'test', + { lifecycleScripts: { test: null }, debug: true }, + spinner + ); + + expect( spinner.info ).not.toHaveBeenCalled(); + } ); + + it( 'should run event option and print output when debugging', async () => { + await executeLifecycleScript( + 'test', + { lifecycleScripts: { test: 'node -v' }, debug: true }, + spinner + ); + + expect( spinner.info ).toHaveBeenCalledWith( + expect.stringMatching( /test Script:\nv[0-9]/ ) + ); + } ); + + it( 'should throw LifecycleScriptError when process errors', async () => { + try { + await executeLifecycleScript( + 'test', + { + lifecycleScripts: { + test: 'node -vvvvvvv', + }, + }, + spinner + ); + } catch ( error ) { + expect( error ).toBeInstanceOf( LifecycleScriptError ); + expect( error.message ).toMatch( /test Error:\n.*bad option/ ); + } + } ); +} ); +/* eslint-enable jest/no-conditional-expect */