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