diff --git a/composer.json b/composer.json index d8084462c..b4746aac5 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "mockery/mockery": "^1.3", "nunomaduro/collision": "^5.0", "phpunit/phpunit": "^9.3.3", + "nunomaduro/termwind": "^1.14", "symplify/monorepo-builder": "^10.1" }, "replace": { diff --git a/src/mantle/testing/class-installation-manager.php b/src/mantle/testing/class-installation-manager.php index 710d54f68..ae10c56bc 100644 --- a/src/mantle/testing/class-installation-manager.php +++ b/src/mantle/testing/class-installation-manager.php @@ -13,6 +13,7 @@ * Installation Manager */ class Installation_Manager { + use Concerns\Rsync_Installation; use Singleton; /** @@ -99,6 +100,11 @@ public function on( string $hook, ?callable $callback, int $priority = 10, int $ public function install() { require_once __DIR__ . '/core-polyfill.php'; + if ( $this->rsync_to ) { + $this->perform_rsync_testsuite(); + return; + } + foreach ( $this->before_install_callbacks as $callback ) { $callback(); } @@ -106,8 +112,8 @@ public function install() { try { require_once __DIR__ . '/wordpress-bootstrap.php'; } catch ( \Throwable $throwable ) { - echo "ERROR: Failed to load WordPress!\n"; - echo "{$throwable}\n"; // phpcs:ignore + Utils::error( '🚨 Failed to load the WordPress installation. Exception thrown:' ); + Utils::code( $throwable->getMessage() ); exit( 1 ); } diff --git a/src/mantle/testing/class-utils.php b/src/mantle/testing/class-utils.php index 7012bf7d5..9d4359881 100644 --- a/src/mantle/testing/class-utils.php +++ b/src/mantle/testing/class-utils.php @@ -8,6 +8,9 @@ namespace Mantle\Testing; use Mantle\Testing\Doubles\Spy_REST_Server; +use function Termwind\render; + +require_once __DIR__ . '/concerns/trait-output-messages.php'; /** * Assorted testing utilities. @@ -15,6 +18,8 @@ * A fork of https://github.com/WordPress/wordpress-develop/blob/master/tests/phpunit/includes/utils.php. */ class Utils { + use Concerns\Output_Messages; + /** * Default database name. * @@ -62,6 +67,7 @@ public static function get_echo( $callable, $args = [] ) { call_user_func_array( $callable, $args ); return ob_get_clean(); } + /** * Unregister a post status. * @@ -197,7 +203,7 @@ public static function setup_configuration(): void { global $table_prefix; // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound - defined( 'ABSPATH' ) || define( 'ABSPATH', preg_replace( '#/wp-content/.*$#', '/', __DIR__ ) ); + defined( 'ABSPATH' ) || define( 'ABSPATH', ensure_trailingslash( preg_replace( '#/wp-content/.*$#', '/', __DIR__ ) ) ); defined( 'WP_DEBUG' ) || define( 'WP_DEBUG', true ); defined( 'DB_NAME' ) || define( 'DB_NAME', static::DEFAULT_DB_NAME ); @@ -254,4 +260,116 @@ public static function env( string $variable, $default ) { public static function shell_safe( string $string ): string { return empty( trim( $string ) ) ? "''" : $string; } + + /** + * Install a WordPress codebase through a shell script. + * + * This installs the WordPress codebase in the specified directory. It does + * not install the WordPress database. + * + * @param string $directory Directory to install WordPress in. + */ + public static function install_wordpress( string $directory ): void { + $command = sprintf( + 'export WP_CORE_DIR=%s && curl -s %s | bash -s %s %s %s %s %s %s', + $directory, + 'https://raw.githubusercontent.com/alleyinteractive/mantle-ci/HEAD/install-wp-tests.sh', + static::shell_safe( defined( 'DB_NAME' ) ? DB_NAME : static::env( 'WP_DB_NAME', 'wordpress_unit_tests' ) ), + static::shell_safe( defined( 'DB_USER' ) ? DB_USER : static::env( 'WP_DB_USER', 'root' ) ), + static::shell_safe( defined( 'DB_PASSWORD' ) ? DB_PASSWORD : static::env( 'WP_DB_PASSWORD', 'root' ) ), + static::shell_safe( defined( 'DB_HOST' ) ? DB_HOST : static::env( 'WP_DB_HOST', 'localhost' ) ), + static::shell_safe( static::env( 'WP_VERSION', 'latest' ) ), + static::shell_safe( static::env( 'WP_SKIP_DB_CREATE', 'false' ) ), + ); + + $output = static::command( $command, $retval ); + + if ( 0 !== $retval ) { + static::error( '🚨 Error installing WordPress! Output from installation:', 'Install Rsync' ); + static::code( $output ); + exit( 1 ); + } + } + + /** + * Check if the command is being run in debug mode. + * + * @return bool + */ + public static function is_debug_mode(): bool { + return ! empty( + array_intersect( + (array) $_SERVER['argv'] ?? [], // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + [ + '--debug', + '--verbose', + '-v', + ], + ) + ); + } + + /** + * Run a system command and return the output. + * + * @param string|string[] $command Command to run. + * @param int $exit_code Exit code. + * @return string[] + */ + public static function command( $command, &$exit_code = null ) { + $is_debug_mode = static::is_debug_mode(); + + // Display the command if in debug mode. + if ( $is_debug_mode ) { + $time = microtime( true ); + + render( + '
+ Running: + ' . implode( ' ', (array) $command ) . ' +
' + ); + } + + if ( is_array( $command ) ) { + $command = implode( ' ', $command ); + } + + exec( $command, $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + + // Display the command runtime if in debug mode. + if ( $is_debug_mode ) { + $time = microtime( true ) - $time; + + render( + '
+ Finished in ' . number_format( $time, 2 ) . 's with exit code ' . $exit_code . '. +
' + ); + } + + return $output; + } + + /** + * Ensure that Composer is loaded for the current environment. + */ + public static function ensure_composer_loaded() { + if ( class_exists( 'Composer\Autoload\ClassLoader' ) ) { + return; + } + + $paths = [ + preg_replace( '#/vendor/.*$#', '/vendor/autoload.php', __DIR__ ), + __DIR__ . '/../../../vendor/autoload.php', + __DIR__ . '/../../vendor/autoload.php', + ]; + + foreach ( $paths as $path ) { + if ( ! is_dir( $path ) && file_exists( $path ) ) { + require_once $path; + return; + } + } + } } diff --git a/src/mantle/testing/composer.json b/src/mantle/testing/composer.json index f3bc28bed..16b66fd9e 100644 --- a/src/mantle/testing/composer.json +++ b/src/mantle/testing/composer.json @@ -10,7 +10,8 @@ "mantle-framework/database": "^0.9", "mantle-framework/http-client": "^0.9", "mantle-framework/http": "^0.9", - "mantle-framework/support": "^0.9" + "mantle-framework/support": "^0.9", + "nunomaduro/termwind": "^1.14" }, "extra": { "branch-alias": { diff --git a/src/mantle/testing/concerns/trait-output-messages.php b/src/mantle/testing/concerns/trait-output-messages.php new file mode 100644 index 000000000..9e4782af4 --- /dev/null +++ b/src/mantle/testing/concerns/trait-output-messages.php @@ -0,0 +1,96 @@ + +
%s:
+ %s + ', + $parent_classes, + $prefix_color, + $message_color, + $prefix, + $message, + ) + ); + } + + /** + * Output a info message to the console. + * + * @param string $message Message to output. + * @param string $prefix Prefix to output. + * @return void + */ + public static function info( string $message, $prefix = 'Install' ): void { + static::message( $prefix, 'yellow-600', $message ); + } + + /** + * Output a success message to the console. + * + * @param string $message Message to output. + * @param string $prefix Prefix to output. + * @return void + */ + public static function success( string $message, $prefix = 'Install' ): void { + static::message( $prefix, 'lime-600', $message ); + } + + /** + * Output a error message to the console. + * + * @param string $message Message to output. + * @param string $prefix Prefix to output. + * @return void + */ + public static function error( string $message, $prefix = 'Install' ): void { + static::message( $prefix, 'red-800', $message, 'red-100', 'pt-1' ); + } + + /** + * Display a formatted code block. + * + * @link https://github.com/nunomaduro/termwind#code + * + * @param string|string[] $code Code to display. + * @return void + */ + public static function code( $code ): void { + if ( is_array( $code ) ) { + $code = implode( PHP_EOL, $code ); + } + + render( "
{$code}
" ); + } +} diff --git a/src/mantle/testing/concerns/trait-rsync-installation.php b/src/mantle/testing/concerns/trait-rsync-installation.php new file mode 100644 index 000000000..b4c5b996f --- /dev/null +++ b/src/mantle/testing/concerns/trait-rsync-installation.php @@ -0,0 +1,246 @@ +rsync_to = $to ?: '/'; + $this->rsync_from = $from ?: getcwd() . '/'; + + return $this; + } + + /** + * Rsync the code base to be located underneath a WordPress installation if it + * isn't already. + * + * @param string $to Location to rsync to. + * @param string $from Location to rsync from. + * @return static + */ + public function maybe_rsync( string $to = null, string $from = null ) { + // Check if we are under an existing WordPress installation. + if ( $this->is_within_wordpress_install() ) { + return $this; + } + + return $this->rsync( $to, $from ); + } + + /** + * Maybe rsync the codebase as a plugin within WordPress. + * + * By default, the from path will be rsynced to `wp-content/plugins/{directory_name}`. + * + * @param string $name Name of the plugin folder, optional. + * @param string $from Location to rsync from. + */ + public function maybe_rsync_plugin( string $name = null, string $from = null ) { + if ( ! $name ) { + $name = basename( getcwd() ); + } + + return $this->maybe_rsync( "plugins/{$name}/", $from ); + } + + /** + * Maybe rsync the codebase as a theme within WordPress. + * + * By default, the from path will be rsynced to `wp-content/themes/{directory_name}`. + * + * @param string $name Name of the theme folder, optional. + * @param string $from Location to rsync from. + */ + public function maybe_rsync_theme( string $name = null, string $from = null ) { + if ( ! $name ) { + $name = basename( getcwd() ); + } + + return $this->maybe_rsync( 'themes', $from ); + } + + /** + * Retrieve the default installation path to rsync to. + * + * @return string + */ + protected function get_installation_path(): string { + return getenv( 'WP_CORE_DIR' ) ?: sys_get_temp_dir() . '/wordpress'; + } + + /** + * Check if the current installation is underneath an existing WordPress + * installation. + * + * @return bool + */ + protected function is_within_wordpress_install(): bool { + return false !== strpos( __DIR__, '/wp-content/' ); + } + + /** + * Rsync the codebase before installation. + * + * This allows the plugin/theme project to properly situate itself within a + * WordPress installation without needing to rsync it manually. + */ + protected function perform_rsync_testsuite() { + require_once __DIR__ . '/../class-utils.php'; + + $base_install_path = $this->get_installation_path(); + + // Normalize the rsync destination. + $this->rsync_to = is_dir( $this->rsync_to ) ? $this->rsync_to : "$base_install_path/wp-content/{$this->rsync_to}"; + + // Define the constants relative to where the codebase is being rsynced to. + defined( 'WP_TESTS_INSTALL_PATH' ) || define( 'WP_TESTS_INSTALL_PATH', $base_install_path ); + defined( 'WP_TESTS_CONFIG_FILE_PATH' ) || define( 'WP_TESTS_CONFIG_FILE_PATH', "{$base_install_path}/wp-tests-config.php" ); + defined( 'ABSPATH' ) || define( 'ABSPATH', ensure_trailingslash( $base_install_path ) ); + + // Install WordPress at the base installation path if it doesn't exist yet. + if ( ! is_dir( $base_install_path ) ) { + Utils::info( + "Installating WordPress at {$base_install_path} ...", + 'Install Rsync' + ); + + // Create the installation directory. + if ( ! is_dir( $base_install_path ) && ! mkdir( $base_install_path, 0777, true ) ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.directory_mkdir + Utils::error( + "Unable to create the WordPress installation directory at {$base_install_path}", + 'Install Rsync' + ); + + exit( 1 ); + } + + Utils::install_wordpress( $base_install_path ); + + Utils::success( + "WordPress installed at {$base_install_path}", + 'Install Rsync' + ); + } else { + Utils::info( + "WordPress already installed at {$base_install_path}", + 'Install Rsync' + ); + } + + Utils::info( + "Rsyncing {$this->rsync_from} to {$this->rsync_to}...", + 'Install Rsync' + ); + + if ( ! is_dir( $this->rsync_to ) && ! mkdir( $this->rsync_to, 0777, true ) ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.directory_mkdir + Utils::error( + "Unable to create destination directory [{$this->rsync_to}]." + ); + + exit( 1 ); + } + + // Rsync the from folder to the destination. + $output = Utils::command( + [ + 'rsync -aWq', + '--no-compress', + '--exclude .npm', + '--exclude .git', + '--exclude node_modules', + '--exclude .composer', + '--exclude .phpcs', + '--exclude .buddy-tests', + "{$this->rsync_from} {$this->rsync_to}", + ], + $retval + ); + + if ( 0 !== $retval ) { + Utils::error( '🚨 Error rsyncing! Output from command:', 'Install Rsync' ); + Utils::code( $output ); + exit( 1 ); + } + + Utils::success( + "Rsynced to {$this->rsync_to} and changed working directory.", + 'Install Rsync' + ); + + chdir( $this->rsync_to ); + + $command = $this->get_phpunit_command(); + + // Proxy to the phpunit instance within the new rsynced WordPress installation. + Utils::info( + "Running {$command} in {$this->rsync_to}:", + 'Install Rsync' + ); + + system( $command, $result_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_system + + exit( (int) $result_code ); + } + + /** + * Generate the command that will be run inside the rsync-ed WordPress + * installation to fire off PHPUnit. + * + * @return string + */ + protected function get_phpunit_command(): string { + $args = (array) $_SERVER['argv'] ?? []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + // Remove the first argument, which is the path to the phpunit binary. + array_shift( $args ); + + $executable = getenv( 'WP_PHPUNIT_PATH' ) ?: 'vendor/bin/phpunit'; + + return $executable . ' ' . implode( ' ', array_map( 'escapeshellarg', $args ) ); + } +} diff --git a/src/mantle/testing/core-polyfill.php b/src/mantle/testing/core-polyfill.php index 2317a291c..031248c47 100644 --- a/src/mantle/testing/core-polyfill.php +++ b/src/mantle/testing/core-polyfill.php @@ -18,3 +18,27 @@ function rand_str( $len = 32 ): string { return substr( md5( uniqid( wp_rand() ) ), 0, $len ); } endif; + +if ( ! function_exists( 'ensure_trailingslash' ) ) : + /** + * Appends a trailing slash. + * + * @param string $string String to append a trailing slash to. + * @return string + */ + function ensure_trailingslash( $string ) { + return remove_trailingslash( $string ) . '/'; + } +endif; + +if ( ! function_exists( 'remove_trailingslash' ) ) : + /** + * Removes trailing forward slashes and backslashes if they exist. + * + * @param string $string What to remove the trailing slashes from. + * @return string String without the trailing slashes. + */ + function remove_trailingslash( $string ) { + return rtrim( $string, '/\\' ); + } +endif; diff --git a/src/mantle/testing/wordpress-bootstrap.php b/src/mantle/testing/wordpress-bootstrap.php index 3b0079b2c..f2104d968 100644 --- a/src/mantle/testing/wordpress-bootstrap.php +++ b/src/mantle/testing/wordpress-bootstrap.php @@ -14,6 +14,9 @@ require_once __DIR__ . '/class-utils.php'; require_once __DIR__ . '/class-wp-die.php'; +// Ensure that Composer is loaded properly in the sub-process. +Utils::ensure_composer_loaded(); + /* * Globalize some WordPress variables, because PHPUnit loads this file inside a function. * See: https://github.com/sebastianbergmann/phpunit/issues/325 @@ -48,38 +51,30 @@ // Install WordPress if we're not in the sub-process that installs WordPress. if ( ! defined( 'WP_INSTALLING' ) || ! WP_INSTALLING ) { - echo 'WordPress installation not found, installing in temporary directory: ' . WP_TESTS_INSTALL_PATH . PHP_EOL; - - // Download the latest installation command from GitHub and install WordPress. - $cmd = sprintf( - 'WP_CORE_DIR=%s curl -s %s | bash -s %s %s %s %s %s %s', - WP_TESTS_INSTALL_PATH, - 'https://raw.githubusercontent.com/alleyinteractive/mantle-ci/HEAD/install-wp-tests.sh', - Utils::shell_safe( defined( 'DB_NAME' ) ? DB_NAME : Utils::env( 'WP_DB_NAME', 'wordpress_unit_tests' ) ), - Utils::shell_safe( defined( 'DB_USER' ) ? DB_USER : Utils::env( 'WP_DB_USER', 'root' ) ), - Utils::shell_safe( defined( 'DB_PASSWORD' ) ? DB_PASSWORD : Utils::env( 'WP_DB_PASSWORD', 'root' ) ), - Utils::shell_safe( defined( 'DB_HOST' ) ? DB_HOST : Utils::env( 'WP_DB_HOST', 'localhost' ) ), - Utils::shell_safe( Utils::env( 'WP_VERSION', 'latest' ) ), - Utils::shell_safe( Utils::env( 'WP_SKIP_DB_CREATE', 'false' ) ), + Utils::info( + 'WordPress installation not found, installing in temporary directory: ' . WP_TESTS_INSTALL_PATH . '' ); - $resp = system( $cmd, $retval ); - - if ( 0 !== $retval ) { - echo "\n🚨 Error downloading WordPress!\nResponse from installation command:\n\n$resp\n" . PHP_EOL; - exit( 1 ); - } + // Download the latest installation command from GitHub and install WordPress. + Utils::install_wordpress( WP_TESTS_INSTALL_PATH ); } } else { // The project is being loaded from inside a WordPress installation. - $config_file_path = preg_replace( '#/wp-content/.*$#', '/wp-tests-config.php', __DIR__ ); + if ( defined( 'WP_TESTS_INSTALL_PATH' ) ) { + $config_file_path = preg_replace( '#/wp-content/.*$#', '/wp-tests-config.php', WP_TESTS_INSTALL_PATH ); + } + + if ( empty( $config_file_path ) ) { + $config_file_path = preg_replace( '#/wp-content/.*$#', '/wp-tests-config.php', __DIR__ ); + } } if ( is_readable( $config_file_path ) ) { - echo "Using configuration file: [{$config_file_path}]\n"; + Utils::info( "Using configuration file: {$config_file_path}" ); + require_once $config_file_path; } elseif ( ! defined( 'WP_INSTALLING' ) || ! WP_INSTALLING ) { - echo "No wp-tests-config.php file found, using default configuration.\n"; + Utils::info( 'No wp-tests-config.php file found, using default configuration.' ); } Utils::setup_configuration(); @@ -117,22 +112,36 @@ $installing_wp = defined( 'WP_INSTALLING' ) && WP_INSTALLING; if ( ! $installing_wp && '1' !== getenv( 'WP_TESTS_SKIP_INSTALL' ) ) { - $resp = system( WP_PHP_BINARY . ' ' . escapeshellarg( __DIR__ . '/install-wordpress.php' ) . ' ' . $multisite, $retval ); + $resp = Utils::command( + [ + WP_PHP_BINARY, + escapeshellarg( __DIR__ . '/install-wordpress.php' ), + $multisite, + ], + $retval, + ); // Verify the return code and that 'Done!' is included in the output. - if ( 0 !== $retval || empty( $resp ) || false === strpos( $resp, 'Done!' ) ) { - echo "🚨 Error installing WordPress!\nResponse from installation script:\n\n$resp\n"; + if ( 0 !== $retval || empty( $resp ) || false === strpos( implode( ' ', $resp ), 'Done!' ) ) { + Utils::error( + '🚨 Error installing WordPress! Response from installation script:' + ); + + Utils::code( $resp ); + exit( $retval ); + } elseif ( Utils::is_debug_mode() ) { + Utils::info( 'WordPress installation complete.' ); } } if ( $multisite && ! $installing_wp ) { - echo 'Running as multisite...' . PHP_EOL; + Utils::info( 'Running as multisite...' ); defined( 'MULTISITE' ) or define( 'MULTISITE', true ); defined( 'SUBDOMAIN_INSTALL' ) or define( 'SUBDOMAIN_INSTALL', false ); $GLOBALS['base'] = '/'; } elseif ( ! $installing_wp ) { - echo "Running as single site...\nℹī¸ To run multisite, pass WP_MULTISITE=1 or set the WP_TESTS_MULTISITE=1 constant.\n"; + Utils::info( "Running as single site...\n
ℹī¸ To run multisite, pass WP_MULTISITE=1 or set the WP_TESTS_MULTISITE=1 constant." ); } unset( $multisite ); diff --git a/tests/.DS_Store b/tests/.DS_Store deleted file mode 100644 index 1659230e9..000000000 Binary files a/tests/.DS_Store and /dev/null differ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 54a9e82e0..26c6361c1 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,4 +10,6 @@ define( 'MANTLE_PHPUNIT_INCLUDES_PATH', __DIR__ . '/includes' ); define( 'MANTLE_PHPUNIT_TEMPLATE_PATH', __DIR__ . '/template-parts' ); -\Mantle\Testing\install(); +\Mantle\Testing\manager() + ->maybe_rsync_plugin() + ->install(); diff --git a/tests/includes/.DS_Store b/tests/includes/.DS_Store deleted file mode 100644 index ba39b3fb1..000000000 Binary files a/tests/includes/.DS_Store and /dev/null differ