*/ private static $db_settings = [ 'dbname' => 'wp_cli_test', 'dbuser' => 'wp_cli_test', 'dbpass' => 'password1', 'dbhost' => '127.0.0.1', ]; /** * What type of database should WordPress use for the test installations. Default to MySQL * * @var string */ private static $db_type = 'mysql'; /** * Name of mysql binary to use (mysql or mariadb). Default to mysql * * @var string */ private static $mysql_binary = 'mysql'; /** * Array of background process ids started by the current scenario. Used to terminate them at the end of the scenario. * * @var array */ private $running_procs = []; /** * Array of temporary file paths created for background processes on Windows. Used to clean them up at the end of the scenario. * * @var array */ private $temp_files = []; /** * Array of variables available as {VARIABLE_NAME}. Some are always set: CORE_CONFIG_SETTINGS, DB_USER, DB_PASSWORD, DB_HOST, SRC_DIR, CACHE_DIR, WP_VERSION-version-latest. * Some are step-dependent: RUN_DIR, SUITE_CACHE_DIR, COMPOSER_LOCAL_REPOSITORY, PHAR_PATH, and PLUGIN_DIR and THEME_DIR for the WordPress files in RUN_DIR. One is set on use: INVOKE_WP_CLI_WITH_PHP_ARGS-args. * Scenarios can define their own variables using "Given save" steps. Variables are reset for each scenario. * * @var array */ public $variables = []; /** * The current feature file and scenario line number as '.'. Used in RUN_DIR and SUITE_CACHE_DIR directory names. Set at the start of each scenario. * * @var ?string */ private static $temp_dir_infix; /** * Whether to log run times - WP_CLI_TEST_LOG_RUN_TIMES env var. Set on `@BeforeScenario'. * * @var false|string */ private static $log_run_times; /** * When the suite started, set on `@BeforeScenario'. * * @var float */ private static $suite_start_time; /** * Where to output log - stdout|error_log. Set on `@BeforeSuite`. * * @var string */ private static $output_to; /** * Number of processes/methods to output by longest run times. Set on `@BeforeSuite`. * * @var int */ private static $num_top_processes; /** * Number of scenarios to output by longest run times. Set on `@BeforeSuite`. * * @var int */ private static $num_top_scenarios; /** * Scenario run times (top `self::$num_top_scenarios` only). * * @var array */ private static $scenario_run_times = []; /** * Scenario count, incremented on `@AfterScenario`. * * @var int */ private static $scenario_count = 0; /** * Array of run time info for proc methods, keyed by method name and arg, each a 2-element array containing run time and run count. * * @var array */ private static $proc_method_run_times = []; /** * @var array */ private $mocked_requests = []; /** * The current feature. * * @var \Behat\Gherkin\Node\FeatureNode|null */ private static $feature; /** * The current scenario. * * @var \Behat\Gherkin\Node\ScenarioInterface|null */ private $scenario; /** * Line of the current step. * * @var int */ private $step_line = 0; /** * @BeforeFeature */ public static function store_feature( BeforeFeatureScope $scope ): void { self::$feature = $scope->getFeature(); } /** * @BeforeScenario */ public function store_scenario( BeforeScenarioScope $scope ): void { $this->scenario = $scope->getScenario(); } /** * @BeforeStep */ public function store_step( BeforeStepScope $scope ): void { $this->step_line = $scope->getStep()->getLine(); } /** * @AfterScenario */ public function forget_scenario( AfterScenarioScope $scope ): void { $this->step_line = 0; $this->scenario = null; } /** * @AfterFeature */ public static function forget_feature( AfterFeatureScope $scope ): void { self::$feature = null; } /** * Whether tests are currently running with code coverage collection. * * @return bool */ private static function running_with_code_coverage() { $with_code_coverage = (string) getenv( 'WP_CLI_TEST_COVERAGE' ); return \in_array( $with_code_coverage, [ 'true', '1' ], true ); } public static function merge_coverage_reports(): void { if ( ! self::running_with_code_coverage() ) { return; } $filter = new Filter(); $coverage = new CodeCoverage( // Selector class was only added in v9.1 of the php-code-coverage library. class_exists( Selector::class ) ? ( new Selector() )->forLineCoverage( $filter ) : ( new Xdebug() ), $filter ); foreach ( new DirectoryIterator( self::$behat_run_dir . '/build/logs' ) as $file ) { if ( ! $file->isFile() || 'cov' !== $file->getExtension() ) { continue; } $coverage->merge( include $file->getPathname() ); unlink( $file->getPathname() ); } ( new Clover() )->process( $coverage, self::$behat_run_dir . '/build/logs/behat-coverage.xml' ); } /** * Get the path to the Composer vendor folder. * * @return string Absolute path to the Composer vendor folder. */ public static function get_vendor_dir(): ?string { static $vendor_folder = null; if ( null !== $vendor_folder ) { return $vendor_folder; } // We try to detect the vendor folder in the most probable locations. $vendor_locations = [ // wp-cli/wp-cli-tests is a dependency of the current working dir. getcwd() . DIRECTORY_SEPARATOR . 'vendor', // wp-cli/wp-cli-tests is the root project. dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'vendor', // wp-cli/wp-cli-tests is a dependency. dirname( __DIR__, 4 ), ]; $vendor_folder = ''; foreach ( $vendor_locations as $location ) { if ( is_dir( $location ) && is_readable( $location ) && is_file( "{$location}/autoload.php" ) ) { $vendor_folder = $location; break; } } return $vendor_folder; } /** * Get the path to the WP-CLI framework folder. * * @return string Absolute path to the WP-CLI framework folder. */ public static function get_framework_dir(): ?string { static $framework_folder = null; if ( null !== $framework_folder ) { return $framework_folder; } $vendor_folder = self::get_vendor_dir(); // Now we need to detect the location of wp-cli/wp-cli package. $framework_locations = [ // wp-cli/wp-cli is the root project. dirname( $vendor_folder ), // wp-cli/wp-cli is a dependency. $vendor_folder . DIRECTORY_SEPARATOR . 'wp-cli' . DIRECTORY_SEPARATOR . 'wp-cli', ]; $framework_folder = ''; foreach ( $framework_locations as $location ) { if ( is_dir( $location ) && is_readable( $location ) && is_file( "{$location}/php/utils.php" ) ) { $framework_folder = $location; break; } } return $framework_folder; } /** * Get the path to the WP-CLI binary. * * @return string Absolute path to the WP-CLI binary. */ public static function get_bin_path(): ?string { static $bin_path = null; if ( null !== $bin_path ) { return $bin_path; } $bin_path = getenv( 'WP_CLI_BIN_DIR' ); if ( ! empty( $bin_path ) ) { return $bin_path; } $bin_paths = [ self::get_vendor_dir() . DIRECTORY_SEPARATOR . 'bin', self::get_framework_dir() . DIRECTORY_SEPARATOR . 'bin', ]; $bin = Utils\is_windows() ? 'wp.bat' : 'wp'; foreach ( $bin_paths as $path ) { $full_bin_path = $path . DIRECTORY_SEPARATOR . $bin; if ( is_file( $full_bin_path ) && ( Utils\is_windows() || is_executable( $full_bin_path ) ) ) { return $path; } } return null; } /** * Get the environment variables required for launched `wp` processes. * * @return array */ private static function get_process_env_variables(): array { static $env = null; if ( null !== $env ) { return $env; } // Ensure we're using the expected `wp` binary. $bin_path = self::get_bin_path(); if ( ! $bin_path ) { throw new RuntimeException( 'Could not find WP-CLI binary path.' ); } self::debug( "WP-CLI binary path: {$bin_path}" ); $bin = Utils\is_windows() ? 'wp.bat' : 'wp'; $full_bin_path = $bin_path . DIRECTORY_SEPARATOR . $bin; if ( ! is_executable( $full_bin_path ) ) { self::debug( "WARNING: File named '{$bin}' found in the provided/detected binary path is not executable." ); } $path_separator = Utils\is_windows() ? ';' : ':'; $php_binary_path = dirname( PHP_BINARY ); $env = [ 'PATH' => $php_binary_path . $path_separator . $bin_path . $path_separator . getenv( 'PATH' ), 'BEHAT_RUN' => 1, 'HOME' => sys_get_temp_dir() . '/wp-cli-home', 'COMPOSER_HOME' => sys_get_temp_dir() . '/wp-cli-composer-home', 'TEST_RUN_DIR' => self::$behat_run_dir, ]; $env = array_merge( $_ENV, $env ); // Keep the launched processes in the same temporary directory as the tests, which differs per // parallel worker. WordPress saves downloads under fixed names there, which would clash otherwise. foreach ( [ 'TMPDIR', 'TEMP', 'TMP', 'SystemRoot', 'windir' ] as $key ) { $value = getenv( $key ); if ( false !== $value ) { $env[ $key ] = $value; } } if ( self::running_with_code_coverage() ) { $has_coverage_driver = ( new Runtime() )->hasXdebug() || ( new Runtime() )->hasPCOV(); if ( ! $has_coverage_driver ) { throw new RuntimeException( 'No coverage driver available. Re-run script with `--xdebug` flag, i.e. `composer behat -- --xdebug`.' ); } $coverage_require_file = self::$behat_run_dir . '/vendor/wp-cli/wp-cli-tests/utils/generate-coverage.php'; if ( ! file_exists( $coverage_require_file ) ) { // This file is not vendored inside the wp-cli-tests project $coverage_require_file = self::$behat_run_dir . '/utils/generate-coverage.php'; } $current = getenv( 'WP_CLI_REQUIRE' ); $updated = $current ? "{$current},{$coverage_require_file}" : $coverage_require_file; $env['WP_CLI_REQUIRE'] = $updated; } $env = array_merge( $env, self::get_php_tuning_env_variables() ); $config_path = getenv( 'WP_CLI_CONFIG_PATH' ); if ( false !== $config_path ) { $env['WP_CLI_CONFIG_PATH'] = $config_path; } $allow_root = getenv( 'WP_CLI_ALLOW_ROOT' ); if ( false !== $allow_root ) { $env['WP_CLI_ALLOW_ROOT'] = $allow_root; } $term = getenv( 'TERM' ); if ( false !== $term ) { $env['TERM'] = $term; } $php_args = getenv( 'WP_CLI_PHP_ARGS' ); if ( false !== $php_args ) { $env['WP_CLI_PHP_ARGS'] = $php_args; } $php_used = getenv( 'WP_CLI_PHP_USED' ); if ( false !== $php_used ) { $env['WP_CLI_PHP_USED'] = $php_used; } $php = getenv( 'WP_CLI_PHP' ); if ( false !== $php ) { $env['WP_CLI_PHP'] = $php; } $travis_build_dir = getenv( 'TRAVIS_BUILD_DIR' ); if ( false !== $travis_build_dir ) { $env['TRAVIS_BUILD_DIR'] = $travis_build_dir; } // Dump environment for debugging purposes, but before adding the GitHub token. self::debug( 'Environment:' ); foreach ( $env as $key => $value ) { self::debug( " [{$key}] => {$value}" ); } $github_token = getenv( 'GITHUB_TOKEN' ); if ( false !== $github_token ) { $env['GITHUB_TOKEN'] = $github_token; } return $env; } /** * Get the environment variables that make the PHP processes launched by the tests cheaper. * * Xdebug is switched off unless code coverage is collected or a mode or session is requested * explicitly. The code of WP-CLI and of the project under test is kept in a file-based opcode * cache shared by all processes. Everything below the temporary directory is excluded from that * cache, as scenarios create and rewrite PHP files there and must always run the current version. * * Set WP_CLI_TEST_PHP_TUNING=off to launch the processes with the PHP configuration as it is. * * @return array */ private static function get_php_tuning_env_variables(): array { if ( 'off' === getenv( 'WP_CLI_TEST_PHP_TUNING' ) || self::running_with_code_coverage() ) { return []; } $env = []; if ( false === getenv( 'XDEBUG_MODE' ) && false === getenv( 'XDEBUG_SESSION' ) && false === getenv( 'XDEBUG_CONFIG' ) ) { $env['XDEBUG_MODE'] = 'off'; } // The file cache cannot be used on its own on Windows. if ( Utils\is_windows() ) { return $env; } $temp_dir = rtrim( sys_get_temp_dir(), '/' ); $config_dir = "{$temp_dir}/wp-cli-test-php-config"; $cache_dir = "{$temp_dir}/wp-cli-test-opcache"; foreach ( [ $config_dir, $cache_dir ] as $dir ) { if ( ! is_dir( $dir ) && ! @mkdir( $dir, 0777, true ) && ! is_dir( $dir ) ) { return $env; } } $excluded = array_unique( array_filter( [ $temp_dir, realpath( $temp_dir ) ] ) ); file_put_contents( "{$config_dir}/opcache-blacklist.txt", implode( "/\n", $excluded ) . "/\n" ); $ini = [ 'opcache.enable_cli=1', "opcache.file_cache={$cache_dir}", 'opcache.file_cache_only=1', "opcache.blacklist_filename={$config_dir}/opcache-blacklist.txt", ]; file_put_contents( "{$config_dir}/wp-cli-tests.ini", implode( "\n", $ini ) . "\n" ); // Add to the directories PHP scans for configuration files rather than replacing them. $scan_dirs = getenv( 'PHP_INI_SCAN_DIR' ); if ( false === $scan_dirs ) { $scan_dirs = PHP_CONFIG_FILE_SCAN_DIR; } $env['PHP_INI_SCAN_DIR'] = ( '' !== $scan_dirs ? $scan_dirs . PATH_SEPARATOR : '' ) . $config_dir; return $env; } /** * Get the internal variables to use within tests. * * @return array Associative array of internal variables that will be mapped * into tests. */ private static function get_behat_internal_variables(): array { static $variables = null; if ( null !== $variables ) { return $variables; } $paths = [ dirname( __DIR__, 4 ) . '/wp-cli/wp-cli/VERSION', dirname( __DIR__, 5 ) . '/VERSION', dirname( __DIR__, 2 ) . '/vendor/wp-cli/wp-cli/VERSION', ]; $framework_root = dirname( __DIR__, 2 ); foreach ( $paths as $path ) { if ( file_exists( $path ) ) { $framework_root = (string) realpath( dirname( $path ) ); break; } } $variables = [ 'FRAMEWORK_ROOT' => realpath( $framework_root ), 'SRC_DIR' => realpath( dirname( __DIR__, 2 ) ), 'PROJECT_DIR' => realpath( dirname( __DIR__, 5 ) ), ]; return $variables; } /** * Download and extract a single copy of the sqlite-database-integration plugin * for use in subsequent WordPress copies * * @param string $dir */ private static function download_sqlite_plugin( $dir ): void { $download_url = 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip'; $download_location = $dir . '/sqlite-database-integration.zip'; if ( ! is_dir( $dir ) ) { mkdir( $dir ); } $response = Utils\http_request( 'GET', $download_url, null, [], [ 'filename' => $download_location ] ); if ( 200 !== $response->status_code ) { throw new RuntimeException( "Could not download SQLite plugin (HTTP code {$response->status_code})" ); } $zip = new \ZipArchive(); $new_zip_file = $download_location; if ( $zip->open( $new_zip_file ) === true ) { if ( $zip->extractTo( $dir ) ) { $zip->close(); unlink( $new_zip_file ); } else { $error_message = $zip->getStatusString(); throw new RuntimeException( sprintf( 'Failed to extract files from the zip: %s', $error_message ) ); } } else { $error_message = $zip->getStatusString(); throw new RuntimeException( sprintf( 'Failed to open the zip file: %s', $error_message ) ); } } /** * Download the SQLite object cache plugin. * * @param string $dir */ private static function download_sqlite_object_cache_plugin( $dir ): void { $download_url = 'https://downloads.wordpress.org/plugin/sqlite-object-cache.zip'; $download_location = $dir . '/sqlite-object-cache.zip'; if ( ! is_dir( $dir ) ) { mkdir( $dir ); } $response = \WP_CLI\Utils\http_request( 'GET', $download_url, null, [], [ 'filename' => $download_location ] ); if ( 200 !== $response->status_code ) { throw new RuntimeException( "Could not download SQLite object cache plugin (HTTP code {$response->status_code})" ); } $zip = new \ZipArchive(); $new_zip_file = $download_location; if ( $zip->open( $new_zip_file ) === true ) { if ( $zip->extractTo( $dir ) ) { $zip->close(); unlink( $new_zip_file ); } else { $error_message = $zip->getStatusString(); throw new RuntimeException( sprintf( 'Failed to extract files from the zip: %s', $error_message ) ); } } else { $error_message = $zip->getStatusString(); throw new RuntimeException( sprintf( 'Failed to open the zip file: %s', $error_message ) ); } } /** * Given a WordPress installation with the sqlite-database-integration plugin, * configure it to use SQLite as the database by placing the db.php dropin file * * @param string $dir */ private static function configure_sqlite( $dir ): void { $db_copy = $dir . '/wp-content/mu-plugins/sqlite-database-integration/db.copy'; $db_dropin = $dir . '/wp-content/db.php'; $db_copy_contents = file_get_contents( $db_copy ); if ( false === $db_copy_contents ) { throw new RuntimeException( "Could not read db.copy file at: {$db_copy}" ); } /* similar to https://github.com/WordPress/sqlite-database-integration/blob/3306576c9b606bc23bbb26c15383fef08e03ab11/activate.php#L95 */ $file_contents = str_replace( array( '\'{SQLITE_IMPLEMENTATION_FOLDER_PATH}\'', '{SQLITE_PLUGIN}', '/plugins/', ), array( '__DIR__ . \'mu-plugins/sqlite-database-integration\'', 'sqlite-database-integration/load.php', '/mu-plugins/', ), $db_copy_contents ); file_put_contents( $db_dropin, $file_contents ); } /** * Resolve the WordPress archive to install from, as configured through the * `WP_CLI_TEST_CORE_ZIP` environment variable. * * The variable accepts either a path to a local ZIP file or an HTTP(S) URL. * Remote archives are downloaded once per suite run. * * @return ?string Path to a local ZIP file, or null if the variable is not set. */ private static function get_core_zip(): ?string { $source = getenv( 'WP_CLI_TEST_CORE_ZIP' ); $source = false === $source ? '' : $source; $resolved = self::$core_zip; if ( null !== $resolved && self::$core_zip_source === $source ) { return false === $resolved ? null : $resolved; } self::$core_zip_source = $source; $core_zip = $source; if ( '' === $core_zip ) { self::$core_zip = false; return null; } if ( preg_match( '#^https?://#i', $core_zip ) ) { $core_zip = self::download_core_zip( $core_zip ); } if ( ! is_file( $core_zip ) || ! is_readable( $core_zip ) ) { throw new RuntimeException( "Could not read the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" ); } $realpath = realpath( $core_zip ); $resolved = false !== $realpath ? $realpath : $core_zip; self::$core_zip = $resolved; return $resolved; } /** * Download a remote WordPress archive to a local file. * * @param string $url * @return string Path to the downloaded file. */ private static function download_core_zip( $url ): string { $download_location = sys_get_temp_dir() . '/wp-cli-test-core-zip-' . substr( md5( $url ), 0, 12 ) . '.zip'; $response = Utils\http_request( 'GET', $url, null, [], [ 'filename' => $download_location, 'timeout' => 600, ] ); if ( 200 !== $response->status_code ) { throw new RuntimeException( "Could not download WordPress archive from {$url} (HTTP code {$response->status_code})" ); } return $download_location; } /** * Get the directory that a given WordPress version is cached in. * * Without an explicit version, this is derived from the contents of the archive configured * through `WP_CLI_TEST_CORE_ZIP`, if any, and from `WP_VERSION` otherwise. * * @param string $version * @return string */ public static function get_core_cache_dir( $version = '' ): string { // An explicit version always takes precedence over a configured archive. $core_zip = $version ? null : self::get_core_zip(); if ( $core_zip ) { $hash = md5_file( $core_zip ); if ( false === $hash ) { throw new RuntimeException( "Could not hash the WP_CLI_TEST_CORE_ZIP archive: {$core_zip}" ); } return sys_get_temp_dir() . '/wp-cli-test-core-download-cache-zip-' . substr( $hash, 0, 12 ); } $wp_version = $version ?: getenv( 'WP_VERSION' ); return sys_get_temp_dir() . '/wp-cli-test-core-download-cache' . ( $wp_version ? "-$wp_version" : '' ); } /** * Extract a WordPress ZIP archive into a destination directory. * * Supports archives that wrap WordPress in a single top-level directory -- * `wordpress/` for wordpress.org releases, `build/` for some WordPress core * build artifacts -- as well as archives that contain WordPress at the root. * * @param string $zip_file * @param string $dest_dir */ public static function extract_wp_zip( $zip_file, $dest_dir ): void { $temp_dir = sys_get_temp_dir() . '/wp-cli-test-core-zip-extract-' . uniqid( '', true ); $zip = new \ZipArchive(); $opened = $zip->open( $zip_file ); if ( true !== $opened ) { // Note that ZipArchive::getStatusString() cannot be used to describe this failure, // as it errors out on an archive that failed to open on PHP < 8.0. throw new RuntimeException( sprintf( 'Failed to open the zip file %s: %s', $zip_file, Extractor::zip_error_msg( (int) $opened ) ) ); } try { self::validate_zip_entries( $zip, $zip_file ); if ( ! $zip->extractTo( $temp_dir ) ) { throw new RuntimeException( sprintf( 'Failed to extract files from the zip %s: %s', $zip_file, $zip->getStatusString() ) ); } } finally { $zip->close(); } try { $source_dir = self::find_wp_root( $temp_dir ); if ( null === $source_dir ) { throw new RuntimeException( "The archive {$zip_file} does not look like a WordPress archive: no wp-includes/version.php found at its root or one level below." ); } self::remove_dir( $dest_dir ); // Both directories live in the system temp folder, so a rename is // normally possible and avoids copying thousands of files. if ( ! @rename( $source_dir, $dest_dir ) ) { // copy_dir() copies into an existing directory, so create it first. if ( ! is_dir( $dest_dir ) && ! mkdir( $dest_dir, 0777, true ) && ! is_dir( $dest_dir ) ) { throw new RuntimeException( "Could not create the WordPress destination directory: {$dest_dir}" ); } self::copy_dir( $source_dir, $dest_dir ); } } finally { self::remove_dir( $temp_dir ); } } /** * Reject archives holding entries that point outside of the directory they are extracted into. * * ZipArchive::extractTo() normalizes such entries rather than following them, but an archive * containing them is malformed for our purposes on any PHP version. * * @param \ZipArchive $zip * @param string $zip_file */ private static function validate_zip_entries( \ZipArchive $zip, $zip_file ): void { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the PHP ZipArchive class. $num_files = $zip->numFiles; for ( $i = 0; $i < $num_files; $i++ ) { $name = $zip->getNameIndex( $i ); if ( false === $name ) { continue; } $segments = explode( '/', str_replace( '\\', '/', $name ) ); // An empty first segment means the entry is an absolute path. if ( in_array( '..', $segments, true ) || '' === $segments[0] || preg_match( '#^[a-zA-Z]:$#', $segments[0] ) ) { throw new RuntimeException( "The archive {$zip_file} contains an entry that would be extracted outside of its destination: {$name}" ); } } } /** * Find the WordPress root within an extracted archive. * * @param string $dir * @return ?string The directory holding wp-includes/version.php, or null if there is none. */ private static function find_wp_root( $dir ): ?string { if ( is_readable( $dir . '/wp-includes/version.php' ) ) { return $dir; } foreach ( new DirectoryIterator( $dir ) as $item ) { if ( ! $item->isDir() || $item->isDot() ) { continue; } $candidate = $item->getPathname(); if ( is_readable( $candidate . '/wp-includes/version.php' ) ) { return $candidate; } } return null; } /** * We cache the SQLite plugins so that they only need to be downloaded once per machine. * * Every code path that installs WordPress has to call this before using the cache directories, * as any of them can be the first step of a scenario. */ private static function cache_sqlite_plugins(): void { self::$sqlite_cache_dir = sys_get_temp_dir() . '/wp-cli-test-sqlite-integration-cache'; if ( 'sqlite' === getenv( 'WP_CLI_TEST_DBTYPE' ) ) { if ( ! is_readable( self::$sqlite_cache_dir . '/sqlite-database-integration/db.copy' ) ) { self::download_sqlite_plugin( self::$sqlite_cache_dir ); } } if ( 'sqlite' === getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ) { self::$sqlite_object_cache_dir = sys_get_temp_dir() . '/wp-cli-test-sqlite-object-cache'; if ( ! is_dir( self::$sqlite_object_cache_dir . '/sqlite-object-cache' ) ) { self::download_sqlite_object_cache_plugin( self::$sqlite_object_cache_dir ); } } } /** * We cache the results of `wp core download` to improve test performance. * Ideally, we'd cache at the HTTP layer for more reliable tests. * * @param string $version */ private static function cache_wp_files( $version = '' ): void { $core_zip = $version ? null : self::get_core_zip(); $wp_version = $version ?: getenv( 'WP_VERSION' ); $cache_dir = self::get_core_cache_dir( $version ); self::cache_sqlite_plugins(); if ( is_readable( $cache_dir . '/wp-includes/version.php' ) ) { self::$cache_dir = $cache_dir; return; } if ( $core_zip ) { self::extract_wp_zip( $core_zip, $cache_dir ); self::$cache_dir = $cache_dir; return; } $cmd = Utils\esc_cmd( 'wp core download --force --path=%s', $cache_dir ); if ( $wp_version ) { $cmd .= Utils\esc_cmd( ' --version=%s', $wp_version ); } $max_retries = 3; $retry_count = 0; $completed = false; // This is in addition to the retry logic inside Utils\http_request(). while ( $retry_count < $max_retries && ! $completed ) { try { Process::create( $cmd, null, self::get_process_env_variables() )->run_check(); $completed = true; } catch ( \Exception $e ) { ++$retry_count; if ( $retry_count >= $max_retries ) { throw $e; } } } self::$cache_dir = $cache_dir; } /** * @BeforeSuite */ public static function prepare( BeforeSuiteScope $scope ): void { self::bootstrap_feature_context(); // Test performance statistics - useful for detecting slow tests. self::$log_run_times = getenv( 'WP_CLI_TEST_LOG_RUN_TIMES' ); if ( false !== self::$log_run_times ) { self::log_run_times_before_suite( $scope ); } self::$behat_run_dir = getcwd(); // TODO: Improve Windows support upstream in Utils\get_mysql_binary_path(). if ( Utils\is_windows() ) { self::$mysql_binary = 'mysql.exe'; } else { self::$mysql_binary = Utils\get_mysql_binary_path(); } $result = Process::create( 'wp cli info', null, self::get_process_env_variables() )->run_check(); echo "{$result->stdout}\n"; // Remove install cache if any (not setting the static var). $wp_version = getenv( 'WP_VERSION' ); $wp_version_suffix = ( false !== $wp_version ) ? "-$wp_version" : ''; $install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; if ( file_exists( $install_cache_dir ) ) { self::remove_dir( $install_cache_dir ); } if ( getenv( 'WP_CLI_TEST_DEBUG_BEHAT_ENV' ) ) { exit; } } /** * @AfterSuite */ public static function afterSuite( AfterSuiteScope $scope ): void { if ( self::$composer_local_repository ) { self::remove_dir( self::$composer_local_repository ); self::$composer_local_repository = null; } if ( self::$log_run_times ) { self::log_run_times_after_suite( $scope ); } self::merge_coverage_reports(); } /** * @BeforeScenario */ public function beforeScenario( BeforeScenarioScope $scope ): void { if ( self::$log_run_times ) { self::log_run_times_before_scenario( $scope ); } $this->variables = array_merge( $this->variables, self::get_behat_internal_variables() ); $sql_dump_command = Utils\get_sql_dump_command(); $this->variables['MYSQL_BINARY'] = self::$mysql_binary; $this->variables['SQL_DUMP_COMMAND'] = $sql_dump_command; // Used in the names of the RUN_DIR and SUITE_CACHE_DIR directories. self::$temp_dir_infix = null; $file = self::get_event_file( $scope, $line ); if ( isset( $file ) ) { self::$temp_dir_infix = basename( $file ) . '.' . $line; } } /** * @AfterScenario */ public function afterScenario( AfterScenarioScope $scope ): void { if ( self::$run_dir ) { // Remove altered WP install, unless there's an error (and we are not on CI). $is_ci = getenv( 'CI' ); $is_debug = getenv( 'WP_CLI_TEST_DEBUG_BEHAT_ENV' ); if ( $scope->getTestResult()->getResultCode() <= 10 || ( $is_ci && ! $is_debug ) ) { self::remove_dir_in_background( self::$run_dir ); } self::$run_dir = null; } // Remove WP-CLI package directory if any. Set to `wp package path` by package-command and scaffold-package-command features, and by cli-info.feature. if ( isset( $this->variables['PACKAGE_PATH'] ) ) { self::remove_dir( $this->variables['PACKAGE_PATH'] ); } // Remove SUITE_CACHE_DIR if any. if ( self::$suite_cache_dir ) { self::remove_dir_in_background( self::$suite_cache_dir ); self::$suite_cache_dir = null; } // Remove global config file if any. $env = self::get_process_env_variables(); if ( isset( $env['HOME'] ) && file_exists( "{$env['HOME']}/.wp-cli/config.yml" ) ) { unlink( "{$env['HOME']}/.wp-cli/config.yml" ); } // Remove any background processes. foreach ( $this->running_procs as $proc ) { $status = proc_get_status( $proc ); self::terminate_proc( $status['pid'] ); } // Clean up temporary files created for background processes on Windows. $this->cleanup_temp_files( ...$this->temp_files ); $this->temp_files = []; if ( self::$log_run_times ) { self::log_run_times_after_scenario( $scope ); } } /** * Terminate a process and any of its children. * * @param int $master_pid */ private static function terminate_proc( $master_pid ): void { $master_pid = (int) $master_pid; if ( Utils\is_windows() ) { shell_exec( "taskkill /F /T /PID $master_pid > NUL 2>&1" ); return; } $output = shell_exec( "ps -o ppid,pid,command | grep $master_pid" ); foreach ( explode( PHP_EOL, $output ? $output : '' ) as $line ) { if ( preg_match( '/^\s*(\d+)\s+(\d+)/', $line, $matches ) ) { $parent = $matches[1]; $child = $matches[2]; if ( (int) $parent === $master_pid ) { self::terminate_proc( (int) $child ); } } } if ( ! function_exists( 'posix_kill' ) ) { return; } if ( ! posix_kill( $master_pid, 9 ) ) { $errno = posix_get_last_error(); // Ignore "No such process" error as that's what we want. if ( 3 /*ESRCH*/ !== $errno ) { throw new RuntimeException( posix_strerror( $errno ) ); } } } /** * Create a temporary WP_CLI_CACHE_DIR. Exposed as SUITE_CACHE_DIR in "Given an empty cache" step. */ public static function create_cache_dir(): string { if ( self::$suite_cache_dir ) { self::remove_dir( self::$suite_cache_dir ); } self::$suite_cache_dir = sys_get_temp_dir() . '/' . uniqid( 'wp-cli-test-suite-cache-' . self::$temp_dir_infix . '-', true ); mkdir( self::$suite_cache_dir ); return self::$suite_cache_dir; } /** * Initializes context. * Every scenario gets its own context object. */ public function __construct() { if ( getenv( 'WP_CLI_TEST_DBROOTUSER' ) ) { $this->variables['DB_ROOT_USER'] = getenv( 'WP_CLI_TEST_DBROOTUSER' ); } if ( false !== getenv( 'WP_CLI_TEST_DBROOTPASS' ) ) { $this->variables['DB_ROOT_PASSWORD'] = getenv( 'WP_CLI_TEST_DBROOTPASS' ); } if ( getenv( 'WP_CLI_TEST_DBNAME' ) ) { $this->variables['DB_NAME'] = getenv( 'WP_CLI_TEST_DBNAME' ); } else { $this->variables['DB_NAME'] = 'wp_cli_test'; } if ( getenv( 'WP_CLI_TEST_DBUSER' ) ) { $this->variables['DB_USER'] = getenv( 'WP_CLI_TEST_DBUSER' ); } else { $this->variables['DB_USER'] = 'wp_cli_test'; } if ( false !== getenv( 'WP_CLI_TEST_DBPASS' ) ) { $this->variables['DB_PASSWORD'] = getenv( 'WP_CLI_TEST_DBPASS' ); } else { $this->variables['DB_PASSWORD'] = 'password1'; } if ( getenv( 'WP_CLI_TEST_DBHOST' ) ) { $this->variables['DB_HOST'] = getenv( 'WP_CLI_TEST_DBHOST' ); } else { $this->variables['DB_HOST'] = 'localhost'; } if ( getenv( 'WP_CLI_TEST_DBTYPE' ) ) { $this->variables['DB_TYPE'] = getenv( 'WP_CLI_TEST_DBTYPE' ); } else { // Auto-detect database type if not explicitly set $this->variables['DB_TYPE'] = Utils\get_db_type(); } if ( getenv( 'MYSQL_TCP_PORT' ) ) { $this->variables['MYSQL_PORT'] = getenv( 'MYSQL_TCP_PORT' ); } if ( getenv( 'MYSQL_HOST' ) ) { $this->variables['MYSQL_HOST'] = getenv( 'MYSQL_HOST' ); } if ( getenv( 'WP_CLI_TEST_DBSOCKET' ) ) { $this->variables['DB_SOCKET'] = getenv( 'WP_CLI_TEST_DBSOCKET' ); } self::$db_settings['dbname'] = $this->variables['DB_NAME']; self::$db_settings['dbuser'] = $this->variables['DB_USER']; self::$db_settings['dbpass'] = $this->variables['DB_PASSWORD']; self::$db_settings['dbhost'] = $this->variables['DB_HOST']; self::$db_type = $this->variables['DB_TYPE']; $this->variables['CORE_CONFIG_SETTINGS'] = Utils\assoc_args_to_str( self::$db_settings ); $this->test_connection(); $this->drop_db(); $this->set_cache_dir(); } /** * @param string $message */ protected static function debug( $message ): void { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed if ( ! getenv( 'WP_CLI_TEST_DEBUG_BEHAT_ENV' ) ) { return; } echo "{$message}\n"; } /** * Load required support files as needed before heading into the Behat context. */ protected static function bootstrap_feature_context(): void { $vendor_folder = self::get_vendor_dir(); self::debug( "Vendor folder location: {$vendor_folder}" ); // Didn't manage to detect a valid vendor folder. if ( empty( $vendor_folder ) ) { return; } // We assume the vendor folder is located in the project root folder. $project_folder = dirname( $vendor_folder ); $framework_folder = self::get_framework_dir(); self::debug( "Framework folder location: {$framework_folder}" ); // Load helper functionality that is needed for the tests. require_once "{$framework_folder}/php/utils.php"; require_once "{$framework_folder}/php/WP_CLI/Process.php"; require_once "{$framework_folder}/php/WP_CLI/ProcessRun.php"; // Manually load Composer file includes by generating a config with require: // statements for each file. $project_composer = "{$project_folder}/composer.json"; if ( ! file_exists( $project_composer ) ) { return; } $composer_contents = file_get_contents( $project_composer ); if ( false === $composer_contents ) { return; } $composer = json_decode( $composer_contents ); if ( empty( $composer->autoload->files ) ) { return; } $contents = "require:\n"; foreach ( $composer->autoload->files as $file ) { $contents .= " - {$project_folder}/{$file}\n"; } $temp_folder = sys_get_temp_dir() . '/wp-cli-package-test'; if ( ! is_dir( $temp_folder ) && ! mkdir( $temp_folder ) && ! is_dir( $temp_folder ) ) { return; } $project_config = "{$temp_folder}/config.yml"; file_put_contents( $project_config, $contents ); putenv( 'WP_CLI_CONFIG_PATH=' . $project_config ); self::debug( "Project config file location: {$project_config}" ); self::debug( "Project config:\n{$contents}" ); } /** * Replace standard {VARIABLE_NAME} variables and the special {INVOKE_WP_CLI_WITH_PHP_ARGS-args} and {WP_VERSION-version-latest} variables. * Note that standard variable names can only contain uppercase letters, digits and underscores and cannot begin with a digit. * * @param string $str * @return string */ public function replace_variables( $str ) { $str = preg_replace_callback( '/\{([A-Z_][A-Z_0-9]*)\}/', [ $this, 'replace_var' ], $str ); if ( false !== strpos( $str, '{INVOKE_WP_CLI_WITH_PHP_ARGS-' ) ) { $str = $this->replace_invoke_wp_cli_with_php_args( $str ); } if ( false !== strpos( $str, '{WP_VERSION-' ) ) { $str = $this->replace_wp_versions( $str ); } return $str; } /** * Substitute {INVOKE_WP_CLI_WITH_PHP_ARGS-args} variables. * * @param string $str * @return string */ private function replace_invoke_wp_cli_with_php_args( $str ) { static $phar_path = null, $shell_path = null; if ( null === $phar_path ) { $phar_path = false; $phar_begin = '#!/usr/bin/env php'; $phar_begin_len = strlen( $phar_begin ); $bin_dir = getenv( 'WP_CLI_BIN_DIR' ); $bin = Utils\is_windows() ? 'wp.bat' : 'wp'; if ( false !== $bin_dir && // A .bat file will never start with a shebang. ! Utils\is_windows() && file_exists( $bin_dir . DIRECTORY_SEPARATOR . $bin ) && (string) file_get_contents( $bin_dir . DIRECTORY_SEPARATOR . $bin, false, null, 0, $phar_begin_len ) === $phar_begin ) { $phar_path = $bin_dir . DIRECTORY_SEPARATOR . $bin; } else { $src_dir = dirname( __DIR__, 2 ); $bin_path = $src_dir . '/bin/' . $bin; $vendor_bin_path = $src_dir . '/vendor/bin/' . $bin; if ( file_exists( $bin_path ) && is_executable( $bin_path ) ) { $shell_path = $bin_path; } elseif ( file_exists( $vendor_bin_path ) && is_executable( $vendor_bin_path ) ) { $shell_path = $vendor_bin_path; } else { $shell_path = $bin; } } } $str = preg_replace_callback( '/{INVOKE_WP_CLI_WITH_PHP_ARGS-(.*)}/', static function ( $matches ) use ( $phar_path, $shell_path ) { return $phar_path ? "php {$matches[1]} {$phar_path}" : ( 'WP_CLI_PHP_ARGS=' . escapeshellarg( $matches[1] ) . ' ' . $shell_path ); }, $str ); return $str; } /** * Replace variables callback. * * @param array $matches * @return string */ private function replace_var( $matches ) { $str = $matches[0]; foreach ( array_slice( $matches, 1 ) as $key ) { $str = str_replace( '{' . $key . '}', array_key_exists( $key, $this->variables ) ? $this->variables[ $key ] : '', $str ); } return $str; } /** * Substitute {WP_VERSION-version-latest} variables. * * @param string $str * @return string */ private function replace_wp_versions( $str ): string { static $wp_versions = null; if ( null === $wp_versions ) { $wp_versions = []; $wp_org_api = new WpOrgApi(); $result = $wp_org_api->get_core_version_check(); if ( is_array( $result ) && ! empty( $result['offers'] ) ) { // Latest version alias. $wp_versions['{WP_VERSION-latest}'] = count( $result['offers'] ) ? $result['offers'][0]['version'] : ''; foreach ( $result['offers'] as $offer ) { $sub_ver = preg_replace( '/(^[0-9]+\.[0-9]+)\.[0-9]+$/', '$1', $offer['version'] ); $sub_ver_key = "{WP_VERSION-{$sub_ver}-latest}"; $main_ver = preg_replace( '/(^[0-9]+)\.[0-9]+$/', '$1', $sub_ver ); $main_ver_key = "{WP_VERSION-{$main_ver}-latest}"; if ( ! isset( $wp_versions[ $main_ver_key ] ) ) { $wp_versions[ $main_ver_key ] = $offer['version']; } if ( ! isset( $wp_versions[ $sub_ver_key ] ) ) { $wp_versions[ $sub_ver_key ] = $offer['version']; } } } } return strtr( $str, $wp_versions ); } /** * Get the file and line number for the current behat scope. * * @param ScenarioScope|FeatureScope|OutlineTested $scope * @param int $line * * @param-out int $line */ private static function get_event_file( $scope, &$line ): ?string { if ( method_exists( $scope, 'getScenario' ) ) { $scenario_feature = $scope->getScenario(); } elseif ( method_exists( $scope, 'getFeature' ) ) { $scenario_feature = $scope->getFeature(); } elseif ( method_exists( $scope, 'getOutline' ) ) { $scenario_feature = $scope->getOutline(); } else { return null; } $line = $scenario_feature->getLine(); if ( method_exists( $scenario_feature, 'getFile' ) ) { return $scenario_feature->getFile(); } // Scenario nodes do not know their file, but the feature they belong to does. if ( method_exists( $scope, 'getFeature' ) ) { return $scope->getFeature()->getFile(); } return null; } /** * Create the RUN_DIR directory, unless already set for this scenario. */ public function create_run_dir(): void { if ( ! isset( $this->variables['RUN_DIR'] ) ) { $temp_run_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid( 'wp-cli-test-run-' . self::$temp_dir_infix . '-', true ); mkdir( $temp_run_dir ); $temp_run_dir = realpath( $temp_run_dir ) ?: $temp_run_dir; self::$run_dir = $temp_run_dir; $this->variables['RUN_DIR'] = self::$run_dir; } } /** * @param string $version */ public function build_phar( $version = 'same' ): void { $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . DIRECTORY_SEPARATOR . uniqid( 'wp-cli-build-', true ) . '.phar'; $is_bundle = false; // Test running against a package installed as a WP-CLI dependency // WP-CLI bundle installed as a project dependency $make_phar_path = self::get_vendor_dir() . '/wp-cli/wp-cli-bundle/utils/make-phar.php'; if ( ! file_exists( $make_phar_path ) ) { // Running against WP-CLI bundle proper $is_bundle = true; $make_phar_path = self::get_vendor_dir() . '/../utils/make-phar.php'; } // Temporarily modify the Composer autoloader used within the Phar // so that it doesn't clash if autoloading is already happening outside of it, // for example when generating code coverage. // This modifies composer.json. if ( $is_bundle && self::running_with_code_coverage() ) { $this->composer_command( 'config autoloader-suffix "WpCliTestsPhar" --working-dir=' . dirname( self::get_vendor_dir() ) ); $this->composer_command( 'dump-autoload --working-dir=' . dirname( self::get_vendor_dir() ) ); } $command = Utils\esc_cmd( 'php -dphar.readonly=0 %1$s %2$s --version=%3$s', $make_phar_path, $this->variables['PHAR_PATH'], $version ); if ( ! Utils\is_windows() ) { $command .= Utils\esc_cmd( ' && chmod +x %s', $this->variables['PHAR_PATH'] ); } $this->proc( $command )->run_check(); // Revert the suffix change again if ( $is_bundle && self::running_with_code_coverage() ) { $this->composer_command( 'config autoloader-suffix "WpCliBundle" --working-dir=' . dirname( self::get_vendor_dir() ) ); $this->composer_command( 'dump-autoload --working-dir=' . dirname( self::get_vendor_dir() ) ); } } /** * @param string $version */ public function download_phar( $version = 'same' ): void { if ( 'same' === $version ) { $version = WP_CLI_VERSION; } $download_url = sprintf( 'https://github.com/wp-cli/wp-cli/releases/download/v%1$s/wp-cli-%1$s.phar', $version ); $this->variables['PHAR_PATH'] = $this->variables['RUN_DIR'] . DIRECTORY_SEPARATOR . uniqid( 'wp-cli-download-', true ) . '.phar'; $response = Utils\http_request( 'GET', $download_url, null, [], [ 'filename' => $this->variables['PHAR_PATH'] ] ); if ( 200 !== $response->status_code ) { throw new RuntimeException( "Could not download WP-CLI PHAR (HTTP code {$response->status_code})" ); } if ( ! Utils\is_windows() ) { chmod( $this->variables['PHAR_PATH'], 0755 ); } } /** * CACHE_DIR is a cache for downloaded test data such as images. Lives until manually deleted. */ private function set_cache_dir(): void { $path = sys_get_temp_dir() . '/wp-cli-test-cache'; if ( ! file_exists( $path ) ) { mkdir( $path ); } $this->variables['CACHE_DIR'] = $path; } /** * Run a MySQL command with `$db_settings`. * * @param string $sql_cmd Command to run. * @param array $assoc_args Optional. Associative array of options. Default empty. * @param bool $add_database Optional. Whether to add dbname to the $sql_cmd. Default false. * @return array{stdout: string, stderr: string, exit_code: int} */ private static function run_sql( $sql_cmd, $assoc_args = [], $add_database = false ) { $default_assoc_args = [ 'host' => self::$db_settings['dbhost'], 'user' => self::$db_settings['dbuser'], 'pass' => self::$db_settings['dbpass'], ]; if ( $add_database ) { $sql_cmd .= ' ' . escapeshellarg( self::$db_settings['dbname'] ); } $send_to_shell = true; if ( isset( $assoc_args['send_to_shell'] ) ) { $send_to_shell = (bool) $assoc_args['send_to_shell']; unset( $assoc_args['send_to_shell'] ); } $start_time = microtime( true ); $result = Utils\run_mysql_command( $sql_cmd, array_merge( $assoc_args, $default_assoc_args ), null, $send_to_shell ); if ( self::$log_run_times ) { self::log_proc_method_run_time( 'run_sql ' . $sql_cmd, $start_time ); } return array_combine( [ 'stdout', 'stderr', 'exit_code' ], $result ); } public function create_db(): void { if ( 'sqlite' === self::$db_type ) { return; } $dbname = self::$db_settings['dbname']; $ssl_flag = 'mariadb' === self::$db_type ? ' --skip-ssl-verify-server-cert' : ''; self::run_sql( self::$mysql_binary . ' --no-defaults' . $ssl_flag, [ 'execute' => "CREATE DATABASE IF NOT EXISTS $dbname" ] ); } /** * Test if the database connection is working. */ public function test_connection(): void { if ( 'sqlite' === self::$db_type ) { return; } $ssl_flag = 'mariadb' === self::$db_type ? ' --skip-ssl-verify-server-cert' : ''; $sql_result = self::run_sql( self::$mysql_binary . ' --no-defaults' . $ssl_flag, [ 'execute' => 'SELECT 1', 'send_to_shell' => false, ] ); if ( 0 !== $sql_result['exit_code'] ) { # WP_CLI output functions are suppressed in behat context. echo 'There was an error connecting to the database:' . \PHP_EOL; if ( ! empty( $sql_result['stderr'] ) ) { echo ' ' . trim( $sql_result['stderr'] ) . \PHP_EOL; } echo 'run `composer prepare-tests` to connect to the database.' . \PHP_EOL; die( $sql_result['exit_code'] ); } elseif ( ! empty( $sql_result['stderr'] ) ) { // There is "error" output but not an exit code. // Probably a warning, still display it. echo trim( $sql_result['stderr'] ) . \PHP_EOL; } } public function drop_db(): void { if ( 'sqlite' === self::$db_type ) { return; } $dbname = self::$db_settings['dbname']; $ssl_flag = 'mariadb' === self::$db_type ? ' --skip-ssl-verify-server-cert' : ''; self::run_sql( self::$mysql_binary . ' --no-defaults' . $ssl_flag, [ 'execute' => "DROP DATABASE IF EXISTS $dbname" ] ); } /** * @param string $command * @param array $assoc_args * @param string $path * @return Process */ public function proc( $command, $assoc_args = [], $path = '' ): Process { if ( ! empty( $assoc_args ) ) { $command .= Utils\assoc_args_to_str( $assoc_args ); } // Prepend 'php ' on Windows if the command starts with the phar path. if ( Utils\is_windows() && isset( $this->variables['PHAR_PATH'] ) && 0 === strpos( $command, $this->variables['PHAR_PATH'] ) ) { $command = 'php ' . $command; } $env = self::get_process_env_variables(); if ( isset( $this->variables['SUITE_CACHE_DIR'] ) ) { $env['WP_CLI_CACHE_DIR'] = $this->variables['SUITE_CACHE_DIR']; } if ( isset( $this->variables['PROJECT_DIR'] ) ) { $env['BEHAT_PROJECT_DIR'] = $this->variables['PROJECT_DIR']; } if ( self::$feature ) { $env['BEHAT_FEATURE_TITLE'] = self::$feature->getTitle(); } if ( $this->scenario ) { $env['BEHAT_SCENARIO_TITLE'] = $this->scenario->getTitle(); } $env['BEHAT_STEP_LINE'] = (string) $this->step_line; $env['WP_CLI_TEST_DBTYPE'] = self::$db_type; if ( isset( $this->variables['RUN_DIR'] ) ) { $cwd = "{$this->variables['RUN_DIR']}/{$path}"; } else { $cwd = null; } return Process::create( $command, $cwd, $env ); } /** * Run several WP-CLI commands one after the other in a single `wp` process. * * Each command has to start with `wp `, and has to succeed. A command can use the * output saved by an earlier command in the same batch as a {VARIABLE}. * * @param array $commands Pairs of command and the name of the variable to save its STDOUT into, or ''. */ public function run_commands_in_single_process( array $commands ): void { $batch_variables = array_filter( array_column( $commands, 1 ) ); $rows = []; foreach ( $commands as list( $command, $save ) ) { // Variables saved within the batch are only known once the process runs. $command = (string) preg_replace_callback( '/\{([A-Z_][A-Z_0-9]*)\}/', function ( $matches ) use ( $batch_variables ) { return in_array( $matches[1], $batch_variables, true ) ? $matches[0] : $this->replace_variables( $matches[0] ); }, $command ); if ( 0 !== strpos( $command, 'wp ' ) ) { throw new RuntimeException( "Only WP-CLI commands can run in a single process, got: {$command}" ); } // These change how WordPress is loaded, which has already happened for every command but the first. if ( preg_match( '/(?:^|\s)--(?:require|path|url|skip-plugins|skip-themes|skip-packages|context|exec|ssh|http)(?:[=\s]|$)/', $command ) ) { throw new RuntimeException( "Commands that change how WordPress is loaded cannot share a process, got: {$command}" ); } $rows[] = [ substr( $command, 3 ), $save ]; } $marker = 'WP_CLI_TEST_SAVED_VARIABLES:'; $started = 'WP_CLI_TEST_STARTED_COMMAND:'; $code = ' $wp_cli_test_row ) { echo "\n' . $started . '{$wp_cli_test_index}\n"; $wp_cli_test_command = strtr( $wp_cli_test_row[0], $wp_cli_test_saved ); $wp_cli_test_result = WP_CLI::runcommand( $wp_cli_test_command, [ "launch" => false, "return" => "all", "exit_error" => false ] ); if ( 0 !== $wp_cli_test_result->return_code ) { fwrite( STDERR, "\$ wp {$wp_cli_test_command}\n{$wp_cli_test_result->stdout}\n{$wp_cli_test_result->stderr}\nexit status: {$wp_cli_test_result->return_code}\n" ); exit( 1 ); } if ( "" !== $wp_cli_test_row[1] ) { $wp_cli_test_saved[ "{" . $wp_cli_test_row[1] . "}" ] = trim( $wp_cli_test_result->stdout, "\n" ); } } echo "\n' . $marker . '" . json_encode( $wp_cli_test_saved ) . "\n"; '; $file = $this->variables['RUN_DIR'] . '/' . uniqid( 'wp-cli-test-commands-', true ) . '.php'; file_put_contents( $file, $code ); try { $result = $this->proc( Utils\esc_cmd( 'wp eval-file %s', $file ) )->run(); } finally { unlink( $file ); } if ( 0 !== $result->return_code ) { $stdout = trim( (string) preg_replace( '/^' . $started . '\d+$/m', '', $result->stdout ) ); throw new RuntimeException( "Running the commands in a single process failed:\n{$result->stderr}\n{$stdout}" ); } $position = strrpos( $result->stdout, $marker ); $saved = false !== $position ? json_decode( trim( substr( $result->stdout, $position + strlen( $marker ) ) ), true ) : null; if ( ! is_array( $saved ) ) { $last = preg_match_all( '/^' . $started . '(\d+)$/m', $result->stdout, $matches ) ? (int) end( $matches[1] ) : null; if ( null !== $last ) { throw new RuntimeException( "The process ended while running `wp {$rows[ $last ][0]}`. A command that exits on its own cannot share a process with other commands." ); } throw new RuntimeException( "Could not read the saved variables from the output:\n{$result->stdout}" ); } foreach ( $saved as $placeholder => $value ) { $this->variables[ trim( $placeholder, '{}' ) ] = $value; } } /** * Start a background process. Will automatically be closed when the tests finish. * * @param string $cmd */ public function background_proc( $cmd ): void { if ( Utils\is_windows() ) { // On Windows, leaving pipes open can cause hangs. // Redirect output to files and close stdin. $stdout_file = tempnam( sys_get_temp_dir(), 'behat-stdout-' ); $stderr_file = tempnam( sys_get_temp_dir(), 'behat-stderr-' ); $descriptors = [ 0 => [ 'pipe', 'r' ], 1 => [ 'file', $stdout_file, 'a' ], 2 => [ 'file', $stderr_file, 'a' ], ]; } else { $descriptors = [ 0 => STDIN, 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ], ]; } // If something else is already listening on the port, the process failing to bind to it // would go unnoticed when waiting for the port, so fall back to waiting the full second. $port = self::get_listening_port( $cmd ); if ( null !== $port && self::is_port_open( $port ) ) { $port = null; } $proc = proc_open( $cmd, $descriptors, $pipes, $this->variables['RUN_DIR'], self::get_process_env_variables() ); if ( Utils\is_windows() ) { fclose( $pipes[0] ); } self::wait_for_background_proc( $proc, $port ); $status = proc_get_status( $proc ); if ( ! $status['running'] ) { if ( Utils\is_windows() ) { $stderr = (string) file_get_contents( $stderr_file ); $stderr = $stderr ? ': ' . $stderr : ''; // Clean up temporary files. $this->cleanup_temp_files( $stdout_file, $stderr_file ); } else { $stderr = is_resource( $pipes[2] ) ? ( ': ' . stream_get_contents( $pipes[2] ) ) : ''; } throw new RuntimeException( sprintf( "Failed to start background process '%s'%s.", $cmd, $stderr ) ); } $this->running_procs[] = $proc; // Track temporary files for cleanup at the end of the scenario. if ( Utils\is_windows() ) { $this->temp_files[] = $stdout_file; $this->temp_files[] = $stderr_file; } } /** * Give a background process up to a second to start. * * Returns early once the process accepts connections on the given port. * * @param resource $proc * @param int|null $port Port the process is expected to listen on, if known. */ private static function wait_for_background_proc( $proc, $port ): void { $deadline = microtime( true ) + 1; while ( microtime( true ) < $deadline ) { $status = proc_get_status( $proc ); if ( ! $status['running'] || ( null !== $port && self::is_port_open( $port ) ) ) { return; } usleep( 50000 ); } } /** * Get the local port a background command is going to listen on, if it can be told from the command. * * @param string $cmd * @return int|null */ private static function get_listening_port( $cmd ): ?int { if ( preg_match( '/(?:localhost|127\.0\.0\.1):(\d+)/', $cmd, $matches ) || preg_match( '/--port[= ](\d+)/', $cmd, $matches ) ) { return (int) $matches[1]; } return null; } /** * Whether a local port accepts connections. * * @param int $port * @return bool */ private static function is_port_open( $port ): bool { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A refused connection is the expected answer for a closed port. $connection = @fsockopen( 'localhost', $port, $errno, $errstr, 0.1 ); if ( false === $connection ) { return false; } fclose( $connection ); return true; } /** * Clean up temporary files safely. * * @param string ...$files File paths to clean up. */ private function cleanup_temp_files( ...$files ): void { foreach ( $files as $file ) { if ( file_exists( $file ) ) { unlink( $file ); } } } /** * @param string $src * @param string $dest */ public function move_files( $src, $dest ): void { rename( $this->variables['RUN_DIR'] . "/$src", $this->variables['RUN_DIR'] . "/$dest" ); } /** * Remove a directory (recursive). * * @param string $dir */ public static function remove_dir( $dir ): void { $dir = Path::normalize( $dir ); $dir = rtrim( $dir, '/\\' ); if ( ! is_dir( $dir ) ) { return; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); # Suppress warnings for instance when deleting SQLite database files, # which appears to emit a warning on Windows. # See https://bugs.php.net/bug.php?id=78930. /** * @var \SplFileInfo $file */ foreach ( $iterator as $file ) { if ( $file->isDir() ) { @rmdir( $file->getPathname() ); } else { @unlink( $file->getPathname() ); } } @rmdir( $dir ); } /** * Remove a directory without waiting for it to be gone. * * Only meant for directories with a unique name, which no later scenario is going to use. * * @param string $dir */ private static function remove_dir_in_background( $dir ): void { if ( Utils\is_windows() || ! is_dir( $dir ) ) { self::remove_dir( $dir ); return; } exec( 'rm -rf ' . escapeshellarg( $dir ) . ' > /dev/null 2>&1 &' ); } /** * Copy a directory (recursive). Destination directory must exist. * * @param string $src_dir * @param string $dest_dir */ public static function copy_dir( $src_dir, $dest_dir ): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $src_dir, \RecursiveDirectoryIterator::SKIP_DOTS ), \RecursiveIteratorIterator::SELF_FIRST ); /** * @var \SplFileInfo $item */ foreach ( $iterator as $item ) { $dest_path = rtrim( $dest_dir, '/\\' ) . DIRECTORY_SEPARATOR . $iterator->getSubPathname(); if ( $item->isDir() ) { if ( ! is_dir( $dest_path ) ) { mkdir( $dest_path, 0777, true ); } } else { copy( $item->getPathname(), $dest_path ); } } } /** * @param string $wp_config_code * @param string $line */ public function add_line_to_wp_config( &$wp_config_code, $line ): void { $token = "/* That's all, stop editing!"; $wp_config_code = str_replace( $token, "$line\n\n$token", $wp_config_code ); } /** * @param string $subdir * @param string $version */ public function download_wp( $subdir = '', $version = '' ): void { $expected_cache_dir = self::get_core_cache_dir( $version ); if ( ! self::$cache_dir || self::$cache_dir !== $expected_cache_dir ) { self::cache_wp_files( $version ); $result = Process::create( Utils\esc_cmd( 'wp core version --debug --path=%s', self::$cache_dir ), null, self::get_process_env_variables() )->run_check(); echo "[Debug messages]\n"; echo "{$result->stderr}\n"; echo "WordPress {$result->stdout}\n"; } $dest_dir = rtrim( $this->variables['RUN_DIR'], '/\\' ) . ( $subdir ? DIRECTORY_SEPARATOR . $subdir : '' ); // The scenario may have created files in the subdirectory already. if ( $subdir && ! is_dir( $dest_dir ) ) { mkdir( $dest_dir, 0777, true /*recursive*/ ); } self::copy_dir( self::$cache_dir, $dest_dir ); if ( ! $subdir ) { $this->set_content_dir_variables( $dest_dir . '/wp-content' ); } if ( ! is_dir( $dest_dir . '/wp-content/mu-plugins' ) ) { mkdir( $dest_dir . '/wp-content/mu-plugins', 0777, true /*recursive*/ ); } // Disable emailing. copy( dirname( __DIR__, 2 ) . '/utils/no-mail.php', $dest_dir . '/wp-content/mu-plugins/no-mail.php' ); // Add polyfills. copy( dirname( __DIR__, 2 ) . '/utils/polyfills.php', $dest_dir . '/wp-content/mu-plugins/polyfills.php' ); if ( 'sqlite' === self::$db_type ) { self::copy_dir( self::$sqlite_cache_dir, $dest_dir . '/wp-content/mu-plugins' ); self::configure_sqlite( $dest_dir ); } if ( 'sqlite' === getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ) { self::copy_dir( self::$sqlite_object_cache_dir, $dest_dir . '/wp-content/mu-plugins' ); copy( $dest_dir . '/wp-content/mu-plugins/sqlite-object-cache/assets/drop-in/object-cache.php', $dest_dir . '/wp-content/object-cache.php' ); file_put_contents( $dest_dir . '/wp-content/mu-plugins/sqlite-object-cache.php', " $lines Slugs, optionally followed by `--version=` and `--force`, or anything `wp plugin install` accepts. * @param bool $activate Whether to activate them. */ public function install_extensions( $type, array $lines, $activate ): void { $target_dir = $this->get_extension_dir( $type ); $slugs = []; $uncached = []; $others = []; foreach ( $lines as $line ) { if ( ! preg_match( '/^([a-z0-9][a-z0-9._-]*)(?:\s+--version=(\S+))?(\s+--force)?$/i', $line, $matches ) || null === $target_dir ) { $others[] = $line; continue; } $slug = $matches[1]; $flags = ( isset( $matches[2] ) && '' !== $matches[2] ? ' --version=' . escapeshellarg( $matches[2] ) : '' ) . ( empty( $matches[3] ) ? '' : ' --force' ); $slugs[] = $slug; $cache_path = self::get_extension_cache_path( $type, $slug, $flags ); if ( is_dir( $cache_path ) ) { self::remove_dir( "$target_dir/$slug" ); mkdir( "$target_dir/$slug" ); self::copy_dir( $cache_path, "$target_dir/$slug" ); // Installing a child theme also installs its parent. $parent = 'theme' === $type ? self::get_parent_theme( "$target_dir/$slug" ) : ''; if ( '' !== $parent && ! is_dir( "$target_dir/$parent" ) ) { $this->install_extensions( 'theme', [ $parent ], false ); } } else { $uncached[ $flags ][] = $slug; } } foreach ( $uncached as $flags => $uncached_slugs ) { $this->proc( "wp $type install " . implode( ' ', array_map( 'escapeshellarg', $uncached_slugs ) ) . $flags )->run_check(); foreach ( $uncached_slugs as $slug ) { if ( is_dir( "$target_dir/$slug" ) ) { self::store_in_extension_cache( "$target_dir/$slug", self::get_extension_cache_path( $type, $slug, (string) $flags ) ); } $parent = 'theme' === $type && is_dir( "$target_dir/$slug" ) ? self::get_parent_theme( "$target_dir/$slug" ) : ''; if ( '' !== $parent && is_dir( "$target_dir/$parent" ) && ! is_dir( self::get_extension_cache_path( 'theme', $parent, '' ) ) ) { self::store_in_extension_cache( "$target_dir/$parent", self::get_extension_cache_path( 'theme', $parent, '' ) ); } } } if ( $others ) { $this->proc( "wp $type install " . implode( ' ', $others ) . ( $activate ? ' --activate' : '' ) )->run_check(); } if ( $activate && $slugs ) { $this->proc( "wp $type activate " . implode( ' ', array_map( 'escapeshellarg', $slugs ) ) )->run_check(); } } /** * Remove every installed theme, like `wp theme delete --all --force` does. */ public function remove_all_themes(): void { $themes_dir = $this->get_extension_dir( 'theme' ); if ( null === $themes_dir ) { throw new RuntimeException( 'Could not find the themes directory.' ); } $entries = scandir( $themes_dir ); foreach ( false === $entries ? [] : $entries as $entry ) { if ( '.' !== $entry[0] && is_dir( "$themes_dir/$entry" ) ) { self::remove_dir( "$themes_dir/$entry" ); } } } /** * Get the directory plugins or themes are installed into, or null if it is not known. * * @param string $type Either 'plugin' or 'theme'. */ private function get_extension_dir( $type ): ?string { if ( ! isset( $this->variables['RUN_DIR'] ) ) { return null; } $run_dir = $this->variables['RUN_DIR']; $candidates = 'plugin' === $type ? [ "$run_dir/my-plugins", "$run_dir/wp-content/plugins", "$run_dir/WordPress/wp-content/plugins" ] : [ "$run_dir/my-content/themes", "$run_dir/wp-content/themes", "$run_dir/WordPress/wp-content/themes" ]; foreach ( $candidates as $candidate ) { if ( is_dir( $candidate ) ) { return $candidate; } } return null; } /** * Get the parent of a theme from the `Template` header of its stylesheet, or '' if it has none. * * @param string $theme_dir */ private static function get_parent_theme( $theme_dir ): string { $stylesheet = "$theme_dir/style.css"; if ( ! is_file( $stylesheet ) ) { return ''; } $header = (string) file_get_contents( $stylesheet, false, null, 0, 8192 ); return preg_match( '/^[ \t\/*#@]*Template:(.*)$/mi', $header, $matches ) ? trim( $matches[1] ) : ''; } /** * @param string $type * @param string $slug * @param string $flags Install flags that change what gets installed. */ private static function get_extension_cache_path( $type, $slug, $flags ): string { $key = implode( ':', [ $type, $slug, $flags, (string) getenv( 'WP_VERSION' ), PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION ] ); return sys_get_temp_dir() . '/wp-cli-test-extension-cache/' . $type . '-' . $slug . '-' . substr( md5( $key ), 0, 12 ); } /** * @param string $source_dir * @param string $cache_path */ private static function store_in_extension_cache( $source_dir, $cache_path ): void { $temp_path = $cache_path . '-' . uniqid( '', true ); mkdir( $temp_path, 0777, true ); self::copy_dir( $source_dir, $temp_path ); // Another process may have filled the cache in the meantime. if ( is_dir( $cache_path ) || ! rename( $temp_path, $cache_path ) ) { self::remove_dir( $temp_path ); } } /** * Create a wp-config.php file. * * @param string $subdir * @param string|false $extra_php */ public function create_config( $subdir = '', $extra_php = false ): void { $params = self::$db_settings; // Replaces all characters that are not alphanumeric or an underscore into an underscore. $params['dbprefix'] = $subdir ? preg_replace( '#[^a-zA-Z\_0-9]#', '_', $subdir ) : 'wp_'; $params['skip-salts'] = true; // Do not check database connection if running SQLite as the check would fail. if ( 'sqlite' === self::$db_type ) { $params['skip-check'] = true; } if ( false !== $extra_php ) { $params['extra-php'] = $extra_php; } $run_dir = '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR']; $config_cache_path = ''; if ( self::$install_cache_dir ) { $config_cache_path = self::$install_cache_dir . '/config_' . md5( implode( ':', $params ) . ':subdir=' . $subdir ); } if ( $config_cache_path && file_exists( $config_cache_path ) ) { copy( $config_cache_path, $run_dir . '/wp-config.php' ); } else { $this->proc( 'wp config create', $params, $subdir )->run_check(); if ( $config_cache_path && file_exists( $run_dir . '/wp-config.php' ) ) { copy( $run_dir . '/wp-config.php', $config_cache_path ); } } } /** * @param string $subdir * @param string $version */ public function install_wp( $subdir = '', $version = '' ): void { $wp_version = $version ?: getenv( 'WP_VERSION' ); $wp_version_suffix = $wp_version ? "-$wp_version" : ''; self::$install_cache_dir = sys_get_temp_dir() . '/wp-cli-test-core-install-cache' . $wp_version_suffix; if ( ! file_exists( self::$install_cache_dir ) ) { mkdir( self::$install_cache_dir ); } $subdir = $this->replace_variables( $subdir ); // Disable WP Cron by default to avoid bogus HTTP requests in CLI context. $config_extra_php = "if ( defined( 'DISABLE_WP_CRON' ) === false ) { define( 'DISABLE_WP_CRON', true ); }\n"; if ( 'sqlite' === getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ) { // Derive a deterministic cache key salt from the install cache directory and subdir $salt = md5( self::$install_cache_dir . '/' . $subdir ); $config_extra_php .= "define( 'WP_CACHE_KEY_SALT', '" . $salt . "' );\n"; } if ( 'sqlite' !== self::$db_type ) { $this->create_db(); } $this->create_run_dir(); // Files the scenario created before installing belong to that scenario only, and must not // end up in the installation cache that later scenarios are set up from. $scenario_files = self::list_dir( '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR'] ); $this->download_wp( $subdir, $version ); $this->create_config( $subdir, $config_extra_php ); $install_args = [ 'url' => 'https://example.com', 'title' => 'WP CLI Site', 'admin_user' => 'admin', 'admin_email' => 'admin@example.com', 'admin_password' => 'password1', 'skip-email' => true, ]; $run_dir = '' !== $subdir ? ( $this->variables['RUN_DIR'] . "/$subdir" ) : $this->variables['RUN_DIR']; $install_cache_path = self::$install_cache_dir . '/install_' . md5( implode( ':', $install_args ) . ':subdir=' . $subdir . ':object_cache=' . getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ); // The cache includes the SQLite database, stored under "wp-content/database". if ( is_dir( $install_cache_path ) ) { self::copy_dir( $install_cache_path, $run_dir ); if ( 'sqlite' !== self::$db_type ) { $ssl_flag = 'mariadb' === self::$db_type ? ' --skip-ssl-verify-server-cert' : ''; self::run_sql( self::$mysql_binary . ' --no-defaults' . $ssl_flag, [ 'execute' => "source {$install_cache_path}.sql" ], true /*add_database*/ ); } } else { $this->proc( 'wp core install', $install_args, $subdir )->run_check(); if ( ! is_dir( $install_cache_path ) ) { mkdir( $install_cache_path ); } self::dir_diff_copy( $run_dir, self::$cache_dir, $install_cache_path ); self::remove_from_dir( $install_cache_path, $scenario_files ); if ( 'sqlite' !== self::$db_type ) { $mysqldump_binary = Utils\get_sql_dump_command(); $mysqldump_binary = Utils\force_env_on_nix_systems( $mysqldump_binary ); $help_output = shell_exec( "{$mysqldump_binary} --help" ); $support_column_statistics = ( null !== $help_output && false !== strpos( $help_output, 'column-statistics' ) ); $ssl_flag = 'mariadb' === self::$db_type ? ' --skip-ssl-verify-server-cert' : ''; $command = "{$mysqldump_binary} --no-defaults{$ssl_flag} --no-tablespaces"; if ( $support_column_statistics ) { $command .= ' --skip-column-statistics'; } self::run_sql( $command, [ 'result-file' => "{$install_cache_path}.sql" ], true /*add_database*/ ); } } } /** * @param string $vendor_directory */ public function install_wp_with_composer( $vendor_directory = 'vendor' ): void { $this->create_run_dir(); $this->create_db(); // Unlike download_wp(), this path never goes through cache_wp_files(), // so the SQLite plugins have to be cached here. self::cache_sqlite_plugins(); $yml_path = $this->variables['RUN_DIR'] . '/wp-cli.yml'; file_put_contents( $yml_path, 'path: WordPress' ); $this->composer_command( 'init --name="wp-cli/composer-test" --type="project"' ); $this->composer_command( 'config vendor-dir ' . $vendor_directory ); $this->composer_command( 'config extra.wordpress-install-dir WordPress' ); // Allow for all Composer plugins to run to avoid warnings. $this->composer_command( 'config --no-plugins allow-plugins true' ); // The roots packages link directly to the official WordPress.org release zips, // so they are published immediately and cannot suffer from repackaging issues. // The "-full" variant ships the default themes and plugins, roots/wordpress does not. $this->composer_command( 'require roots/wordpress-core-installer roots/wordpress-full --optimize-autoloader' ); // Disable WP Cron by default to avoid bogus HTTP requests in CLI context. $config_extra_php = "if ( defined( 'DISABLE_WP_CRON' ) === false ) { define( 'DISABLE_WP_CRON', true ); }\n"; $config_extra_php .= "require_once dirname(__DIR__) . '/" . $vendor_directory . "/autoload.php';\n"; if ( 'sqlite' === getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ) { // Use a deterministic salt per install to allow create_config() to reuse cached configs. $config_extra_php .= "define( 'WP_CACHE_KEY_SALT', '" . md5( $this->variables['RUN_DIR'] ) . "' );\n"; } $this->create_config( 'WordPress', $config_extra_php ); $install_args = [ 'url' => 'http://localhost:8080', 'title' => 'WP CLI Site with both WordPress and wp-cli as Composer dependencies', 'admin_user' => 'admin', 'admin_email' => 'admin@example.com', 'admin_password' => 'password1', 'skip-email' => true, ]; if ( ! is_dir( $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins' ) ) { mkdir( $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins' ); } if ( 'sqlite' === self::$db_type ) { mkdir( $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins/sqlite-database-integration' ); self::copy_dir( self::$sqlite_cache_dir, $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins' ); self::configure_sqlite( $this->variables['RUN_DIR'] . '/WordPress' ); } if ( 'sqlite' === getenv( 'WP_CLI_TEST_OBJECT_CACHE' ) ) { self::copy_dir( self::$sqlite_object_cache_dir, $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins' ); copy( $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins/sqlite-object-cache/assets/drop-in/object-cache.php', $this->variables['RUN_DIR'] . '/WordPress/wp-content/object-cache.php' ); file_put_contents( $this->variables['RUN_DIR'] . '/WordPress/wp-content/mu-plugins/sqlite-object-cache.php', "proc( 'wp core install', $install_args )->run_check(); $this->set_content_dir_variables( $this->variables['RUN_DIR'] . '/WordPress/wp-content' ); } /** * Expose the plugins and themes directories as {PLUGIN_DIR} and {THEME_DIR}. * * Matches the output of `wp plugin path` and `wp theme path`. * * @param string $content_dir * @param string $plugin_dir Defaults to the plugins directory inside $content_dir. */ public function set_content_dir_variables( $content_dir, $plugin_dir = '' ): void { $this->variables['PLUGIN_DIR'] = $plugin_dir ?: $content_dir . '/plugins'; $this->variables['THEME_DIR'] = $content_dir . '/themes'; } public function composer_add_wp_cli_local_repository(): void { if ( ! self::$composer_local_repository ) { self::$composer_local_repository = sys_get_temp_dir() . '/' . uniqid( 'wp-cli-composer-local-', true ); mkdir( self::$composer_local_repository ); $env = self::get_process_env_variables(); $src = $env['TRAVIS_BUILD_DIR'] ?? realpath( self::get_vendor_dir() . '/../' ); self::copy_dir( $src, self::$composer_local_repository . '/' ); self::remove_dir( self::$composer_local_repository . '/.git' ); self::remove_dir( self::$composer_local_repository . '/vendor' ); } if ( Utils\is_windows() ) { $json_config = '{\"type\": \"path\", \"url\": \"' . str_replace( '\\', '/', self::$composer_local_repository ) . '\", \"options\": {\"symlink\": false, \"versions\": { \"wp-cli/wp-cli\": \"dev-main\"}}}'; $this->composer_command( "config repositories.wp-cli \"$json_config\"" ); } else { $this->composer_command( "config repositories.wp-cli '{\"type\": \"path\", \"url\": \"" . self::$composer_local_repository . "\", \"options\": {\"symlink\": false, \"versions\": { \"wp-cli/wp-cli\": \"dev-main\"}}}'" ); } $this->variables['COMPOSER_LOCAL_REPOSITORY'] = self::$composer_local_repository; } public function composer_require_current_wp_cli(): void { $this->composer_add_wp_cli_local_repository(); // TODO: Specific alias version should be deduced to keep up-to-date. $this->composer_command( 'require "wp-cli/wp-cli:dev-main as 2.5.x-dev" --optimize-autoloader' ); } /** * @param string $subdir */ public function start_php_server( $subdir = '' ): void { $dir = $this->variables['RUN_DIR'] . '/'; if ( $subdir ) { $dir .= trim( $subdir, '/' ) . '/'; } $cmd = Utils\esc_cmd( '%s -S %s -t %s -c %s', Utils\get_php_binary(), 'localhost:8080', $dir, get_cfg_var( 'cfg_file_path' ) ); // Route requests through the router script of wp-cli/server-command when // it is available, so that a WordPress installation gets pretty permalinks. // Without a router, PHP's built-in web server serves the directory as-is, // which is what a plain directory of static files needs. Passing a router // path that does not exist would make every request fail with a fatal error. $router = $this->get_php_server_router(); if ( null !== $router ) { $cmd .= ' ' . escapeshellarg( $router ); } $this->background_proc( $cmd ); } /** * Locate the router script of wp-cli/server-command, if it is installed. * * The Composer vendor directory of the run directory is checked first, so * that a scenario which installs the package itself takes precedence, * followed by the vendor directory of the project under test. * * @return string|null Absolute path to the router script, or null if none was found. */ private function get_php_server_router(): ?string { $candidates = [ $this->variables['RUN_DIR'] . '/' . $this->get_run_dir_composer_vendor_dir(), self::get_vendor_dir(), ]; foreach ( $candidates as $vendor_dir ) { $router = $vendor_dir . '/wp-cli/server-command/router.php'; if ( is_file( $router ) ) { return $router; } } return null; } /** * Get the Composer vendor directory configured in the run directory. * * Honors a custom `vendor-dir` in the run directory's composer.json, as set * up by `Given a WP installation with Composer and a custom vendor directory`. * * @return string Vendor directory, relative to the run directory. */ private function get_run_dir_composer_vendor_dir(): string { $composer_json = $this->variables['RUN_DIR'] . '/composer.json'; if ( ! is_file( $composer_json ) ) { return 'vendor'; } $composer_data = json_decode( (string) file_get_contents( $composer_json ), true ); if ( is_array( $composer_data ) && isset( $composer_data['config'] ) && is_array( $composer_data['config'] ) && isset( $composer_data['config']['vendor-dir'] ) && is_string( $composer_data['config']['vendor-dir'] ) && '' !== $composer_data['config']['vendor-dir'] ) { return trim( $composer_data['config']['vendor-dir'], '/' ); } return 'vendor'; } /** * @param string $cmd */ private function composer_command( $cmd ): void { if ( ! isset( $this->variables['COMPOSER_PATH'] ) ) { $command = Utils\is_windows() ? 'where composer' : 'which composer'; $path = exec( $command ); if ( false === $path || empty( $path ) ) { throw new RuntimeException( 'Could not find composer.' ); } // In case of multiple paths, pick the first one. $path = strtok( $path, PHP_EOL ); $this->variables['COMPOSER_PATH'] = $path; } $this->proc( $this->variables['COMPOSER_PATH'] . ' --no-interaction ' . $cmd )->run_check(); } /** * Initialize run time logging. * * @param BeforeSuiteScope $scope */ private static function log_run_times_before_suite( BeforeSuiteScope $scope ): void { self::$suite_start_time = microtime( true ); Process::$log_run_times = true; $travis = getenv( 'TRAVIS' ); // Default output settings. self::$output_to = 'stdout'; self::$num_top_processes = $travis ? 10 : 40; self::$num_top_scenarios = $travis ? 10 : 20; // Allow setting of above with "WP_CLI_TEST_LOG_RUN_TIMES=[,][,]" formatted env var. if ( preg_match( '/^(stdout|error_log)?(,[0-9]+)?(,[0-9]+)?$/i', self::$log_run_times, $matches ) ) { if ( isset( $matches[1] ) ) { self::$output_to = strtolower( $matches[1] ); } if ( isset( $matches[2] ) ) { self::$num_top_processes = max( (int) substr( $matches[2], 1 ), 1 ); } if ( isset( $matches[3] ) ) { self::$num_top_scenarios = max( (int) substr( $matches[3], 1 ), 1 ); } } } /** * Record the start time of the scenario into the `$scenario_run_times` array. * * @param ScenarioScope|FeatureScope|OutlineTested $scope */ private static function log_run_times_before_scenario( $scope ): void { $scenario_key = self::get_scenario_key( $scope ); if ( $scenario_key ) { self::$scenario_run_times[ $scenario_key ] = -microtime( true ); } } /** * Save the run time of the scenario into the `$scenario_run_times` array. Only the top `self::$num_top_scenarios` are kept. * * @param ScenarioScope|FeatureScope|OutlineTested $scope */ private static function log_run_times_after_scenario( $scope ): void { $scenario_key = self::get_scenario_key( $scope ); if ( $scenario_key ) { self::$scenario_run_times[ $scenario_key ] += microtime( true ); ++self::$scenario_count; if ( count( self::$scenario_run_times ) > self::$num_top_scenarios ) { arsort( self::$scenario_run_times ); array_pop( self::$scenario_run_times ); } } } /** * List the files and directories within a directory, relative to it. * * Symbolic links are listed, but not followed. * * @param string $dir * @return array Whether each entry is a symbolic link, keyed by relative path, directories after * their contents. Empty if the directory does not exist. */ private static function list_dir( $dir ): array { $dir = rtrim( $dir, '/\\' ); if ( ! is_dir( $dir ) ) { return []; } $paths = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); /** * @var \SplFileInfo $item */ foreach ( $iterator as $item ) { $paths[ substr( $item->getPathname(), strlen( $dir ) + 1 ) ] = $item->isLink(); } return $paths; } /** * Remove the given files from a directory, as well as the given directories once they are empty. * * A copy of what a symbolic link pointed to is removed with all its contents. * * @param string $dir * @param array $paths Whether each entry was a symbolic link, keyed by relative path, directories * after their contents. */ private static function remove_from_dir( $dir, array $paths ): void { foreach ( $paths as $path => $was_link ) { $full_path = "{$dir}/{$path}"; if ( $was_link && is_dir( $full_path ) && ! is_link( $full_path ) ) { self::remove_dir( $full_path ); } elseif ( is_dir( $full_path ) && ! is_link( $full_path ) ) { // Only succeeds for directories that are empty by now. @rmdir( $full_path ); } elseif ( file_exists( $full_path ) || is_link( $full_path ) ) { unlink( $full_path ); } } } /** * Copy files in updated directory that are not in source directory to copy directory. ("Incremental backup".) * Note: does not deal with changed files (ie does not compare file contents for changes), for speed reasons. * * @param string $upd_dir The directory to search looking for files/directories not in `$src_dir`. * @param string $src_dir The directory to be compared to `$upd_dir`. * @param string $cop_dir Where to copy any files/directories in `$upd_dir` but not in `$src_dir` to. */ private static function dir_diff_copy( $upd_dir, $src_dir, $cop_dir ): void { $files = scandir( $upd_dir ); if ( false === $files ) { $error = error_get_last(); throw new RuntimeException( sprintf( "Failed to open updated directory '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_dir, $error['message'] ) ); } foreach ( array_diff( $files, [ '.', '..' ] ) as $file ) { $upd_file = $upd_dir . '/' . $file; $src_file = $src_dir . '/' . $file; $cop_file = $cop_dir . '/' . $file; if ( ! file_exists( $src_file ) ) { if ( is_dir( $upd_file ) ) { if ( ! file_exists( $cop_file ) && ! mkdir( $cop_file, 0777, true /*recursive*/ ) ) { $error = error_get_last(); throw new RuntimeException( sprintf( "Failed to create copy directory '%s': %s. " . __FILE__ . ':' . __LINE__, $cop_file, $error['message'] ) ); } self::copy_dir( $upd_file, $cop_file ); } elseif ( ! file_exists( $upd_file ) ) { continue; // File vanished before it could be copied. } else { if ( ! is_dir( dirname( $cop_file ) ) ) { mkdir( dirname( $cop_file ), 0777, true ); } if ( ! copy( $upd_file, $cop_file ) ) { $error = error_get_last(); throw new RuntimeException( sprintf( "Failed to copy '%s' to '%s': %s. " . __FILE__ . ':' . __LINE__, $upd_file, $cop_file, $error['message'] ) ); } } } elseif ( is_dir( $upd_file ) ) { self::dir_diff_copy( $upd_file, $src_file, $cop_file ); } } } /** * Get the scenario key used for `$scenario_run_times` array. * Format " :", eg "core-command core-update.feature:221". * * @param ScenarioScope|FeatureScope|OutlineTested $scope */ private static function get_scenario_key( $scope ): string { $scenario_key = ''; $file = self::get_event_file( $scope, $line ); if ( isset( $file ) ) { $scenario_grandparent = Path::basename( dirname( $file, 2 ) ); $scenario_key = $scenario_grandparent . ' ' . Path::basename( $file ) . ':' . $line; } return $scenario_key; } /** * Print out stats on the run times of processes and scenarios. */ private static function log_run_times_after_suite( AfterSuiteScope $scope ): void { $suite = ''; if ( self::$scenario_run_times ) { // Grandparent directory is first part of key. $keys = array_keys( self::$scenario_run_times ); $suite = substr( $keys[0], 0, strpos( $keys[0], ' ' ) ); } $run_from = Path::basename( dirname( __DIR__, 2 ) ); // Format same as Behat, if have minutes. $fmt = static function ( $time ) { $mins = floor( $time / 60 ); return round( $time, 3 ) . ( $mins ? ( ' (' . $mins . 'm' . round( $time - ( $mins * 60 ), 3 ) . 's)' ) : '' ); }; $time = microtime( true ) - self::$suite_start_time; $log = PHP_EOL . str_repeat( '(', 80 ) . PHP_EOL; // Process and proc method run times. $run_times = array_merge( Process::$run_times, self::$proc_method_run_times ); $reduce_callback = static function ( $carry, $item ) { return [ $carry[0] + $item[0], $carry[1] + $item[1] ]; }; list( $ptime, $calls ) = array_reduce( $run_times, $reduce_callback, [ 0, 0 ] ); $overhead = $time - $ptime; $pct = round( ( $overhead / $time ) * 100 ); $unique = count( $run_times ); $log .= sprintf( PHP_EOL . "Total process run time %s (tests %s, overhead %.3f %d%%), calls %d (%d unique) for '%s' run from '%s'" . PHP_EOL, $fmt( $ptime ), $fmt( $time ), $overhead, $pct, $calls, $unique, $suite, $run_from ); $sort_callback = static function ( $a, $b ) { return $a[0] === $b[0] ? 0 : ( $a[0] < $b[0] ? 1 : -1 ); // Reverse sort. }; uasort( $run_times, $sort_callback ); $tops = array_slice( $run_times, 0, self::$num_top_processes, true ); $runtime_callback = static function ( $k, $v, $i ) { return sprintf( ' %3d. %7.3f %3d %s', $i + 1, round( $v[0], 3 ), $v[1], $k ); }; $log .= PHP_EOL . 'Top ' . self::$num_top_processes . " process run times for '$suite'"; $log .= PHP_EOL . implode( PHP_EOL, array_map( $runtime_callback, array_keys( $tops ), $tops, array_keys( array_keys( $tops ) ) ) ) . PHP_EOL; // Scenario run times. arsort( self::$scenario_run_times ); $tops = array_slice( self::$scenario_run_times, 0, self::$num_top_scenarios, true ); $scenario_runtime_callback = static function ( $k, $v, $i ) { return sprintf( ' %3d. %7.3f %s', $i + 1, round( $v, 3 ), substr( $k, strpos( $k, ' ' ) + 1 ) ); }; $log .= PHP_EOL . 'Top ' . self::$num_top_scenarios . ' (of ' . self::$scenario_count . ") scenario run times for '$suite'"; $log .= PHP_EOL . implode( PHP_EOL, array_map( $scenario_runtime_callback, array_keys( $tops ), $tops, array_keys( array_keys( $tops ) ) ) ) . PHP_EOL; $log .= PHP_EOL . str_repeat( ')', 80 ); if ( 'error_log' === self::$output_to ) { error_log( $log ); } else { // Behat buffers what hooks echo and only prints it for failed scenarios, // so write to the stream directly to get the report in every run. fwrite( STDOUT, PHP_EOL . $log . PHP_EOL ); } } /** * Log the run time of a proc method (one that doesn't use Process but does (use a function that does) a `proc_open()`). * * @param string $key * @param int|float $start_time */ private static function log_proc_method_run_time( $key, $start_time ): void { $run_time = microtime( true ) - $start_time; if ( ! isset( self::$proc_method_run_times[ $key ] ) ) { self::$proc_method_run_times[ $key ] = [ 0, 0 ]; } self::$proc_method_run_times[ $key ][0] += $run_time; ++self::$proc_method_run_times[ $key ][1]; } }