From 60906068fb860bc092efd08a70ef38de743d9dd9 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 21 May 2024 09:55:43 +0200 Subject: [PATCH] feat(WPLoader) support arbitrary paths for plugins --- CHANGELOG.md | 4 + docs/modules/WPLoader.md | 26 +- src/Module/WPLoader.php | 84 +++++- .../CodeExecution/ActivatePluginAction.php | 65 +++- tests/_data/plugins/exploding-plugin/main.php | 10 + .../some-external-plugin/some-plugin.php | 9 + tests/_support/Traits/LoopIsolation.php | 2 - .../WPBrowser/Module/WPLoaderTest.php | 283 ++++++++++++++++++ 8 files changed, 463 insertions(+), 20 deletions(-) create mode 100644 tests/_data/plugins/exploding-plugin/main.php create mode 100644 tests/_data/plugins/some-external-plugin/some-plugin.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e3dac22..2e33f10d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [unreleased] Unreleased +### Added + +- Allow plugins to be loaded from arbitrary paths in the `WPLoader` module. + ## [4.1.9] 2024-05-18; ## Fixed diff --git a/docs/modules/WPLoader.md b/docs/modules/WPLoader.md index 31523f0e9..99309a6c8 100644 --- a/docs/modules/WPLoader.md +++ b/docs/modules/WPLoader.md @@ -46,16 +46,18 @@ When used in this mode, the module supports the following configuration paramete and control the WordPress testing environment. * `pluginsFolder` - the path to the plugins folder to use when loading WordPress. Equivalent to defining the `WP_PLUGIN_DIR` constant. -* `plugins` - a list of plugins to activate and load in the WordPress installation. Each plugin must be specified in a - format like `hello.php` or `my-plugin/my-plugin.php` format. +* `plugins` - a list of plugins to activate and load in the WordPress installation. If the plugin is located in the + WordPress installation plugins directory, then the plugin name can be specified using the `directory/file.php` format. + If the plugin is located in an arbitrary path inside or outiside of the WordPress installation or project, then the + plugin name must be specified either as an absolute path or as a relative path to the project root folder. * `silentlyActivatePlugins` - a list of plugins to activate **silently**, without firing their activation hooks. Depending on the plugin, a silent activation might cause the plugin to not work correctly. The list must be in the same format as the `plugins` parameter and plugin should be activated silently only if they are not working correctly - during normal activation and are known to work correctly when activated silently. + during normal activation and are known to work correctly when activated silently. Plugin paths can be specified + following the same format of the `plugins` parameter. * `bootstrapActions` - a list of actions or callables to call **after** WordPress is loaded and before the tests run. * `theme` - the theme to activate and load in the WordPress installation. The theme must be specified in slug format - like - `twentytwentythree`. + like `twentytwentythree`. * `AUTH_KEY` - the `AUTH_KEY` constant value to use when loading WordPress. If the `wpRootFolder` path points at a configured installation, containing the `wp-config.php` file, then the value of the constant in the configuration file will be used, else it will be randomly generated. @@ -126,9 +128,10 @@ modules: adminEmail: admin@wordpress.test title: 'Integration Tests' plugins: - - hello.php - - woocommerce/woocommerce.php - - my-plugin/my-plugin.php + - hello.php # This plugin will be loaded from the WordPress installation plugins directory. + - /home/plugins/woocommerce/woocommerce.php # This plugin will be loaded from an arbitrary absolute path. + - vendor/acme/project/plugin.php # This plugin will be loaded from an arbitrary relative path inside the project root folder. + - my-plugin.php # This plugin will be loaded from the project root folder. theme: twentytwentythree ``` @@ -148,9 +151,10 @@ modules: adminEmail: '%WP_ADMIN_EMAIL%' title: '%WP_TITLE%' plugins: - - hello.php - - woocommerce/woocommerce.php - - my-plugin/my-plugin.php + - hello.php # This plugin will be loaded from the WordPress installation plugins directory. + - /home/plugins/woocommerce/woocommerce.php # This plugin will be loaded from an arbitrary absolute path. + - my-plugin.php # This plugin will be loaded from the project root folder. + - vendor/acme/project/plugin.php # This plugin will be loaded from an arbitrary relative path inside the project root folder. theme: twentytwentythree ``` diff --git a/src/Module/WPLoader.php b/src/Module/WPLoader.php index 4b5e1fcda..650b8d0d4 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -622,17 +622,17 @@ private function installAndBootstrapInstallation(): void $skipInstall = ($this->config['skipInstall'] ?? false) && !Debug::isEnabled() && $this->isWordPressInstalled(); + $isMultisite = $this->config['multisite']; + $plugins = (array)$this->config['plugins']; Dispatcher::dispatch(self::EVENT_BEFORE_INSTALL, $this); if (!$skipInstall) { putenv('WP_TESTS_SKIP_INSTALL=0'); - $isMultisite = $this->config['multisite']; - $plugins = (array)$this->config['plugins']; /* * The bootstrap file will load the `wp-settings.php` one that will load plugins and the theme. - * Hook on the option to get the the active plugins to run the plugins' and theme activation + * Hook on the option to get the active plugins to run the plugins' and theme activation * in a separate process. */ if ($isMultisite) { @@ -654,6 +654,8 @@ private function installAndBootstrapInstallation(): void putenv('WP_TESTS_SKIP_INSTALL=1'); } + $silentPlugins = $this->config['silentlyActivatePlugins']; + $this->includeAllPlugins(array_merge($plugins, $silentPlugins), $isMultisite); $this->includeCorePHPUniteSuiteBootstrapFile(); Dispatcher::dispatch(self::EVENT_AFTER_INSTALL, $this); @@ -1029,7 +1031,15 @@ private function activatePluginsTheme(array $plugins): array // Flush the cache to force the refetch of the options' value. wp_cache_delete('alloptions', 'options'); - return $plugins; + // Do not include external plugins, it would create issues at this stage. + $pluginsDir = $this->installation->getPluginsDir(); + + return array_values( + array_filter( + $plugins, + static fn(string $plugin) => is_file($pluginsDir . "/$plugin") + ) + ); } /** @@ -1062,8 +1072,22 @@ private function muActivatePluginsTheme(array $plugins): array // Flush the cache to force the refetch of the options' value. wp_cache_delete("1::active_sitewide_plugins", 'site-options'); + // Do not include external plugins, it would create issues at this stage. + $pluginsDir = $this->installation->getPluginsDir(); + $validPlugins = array_values( + array_filter( + $plugins, + static fn(string $plugin) => is_file($pluginsDir . "/$plugin") + ) + ); + // Format for site-wide active plugins is `[ 'plugin-slug/plugin.php' => timestamp ]`. - return array_combine($plugins, array_fill(0, count($plugins), time())); + $validActiveSitewidePlugins = array_combine( + $validPlugins, + array_fill(0, count($validPlugins), time()) + ); + + return $validActiveSitewidePlugins; } private function isWordPressInstalled(): bool @@ -1078,4 +1102,54 @@ private function isWordPressInstalled(): bool return false; } } + + /** + * @param string[] $plugins + * @throws ModuleConfigException + */ + private function includeAllPlugins(array $plugins, bool $isMultisite): void + { + PreloadFilters::addFilter('plugins_loaded', function () use ($plugins, $isMultisite) { + $activePlugins = $isMultisite ? get_site_option('active_sitewide_plugins') : get_option('active_plugins'); + + if (!is_array($activePlugins)) { + $activePlugins = []; + } + + $pluginsDir = $this->installation->getPluginsDir(); + + foreach ($plugins as $plugin) { + if (!is_file($pluginsDir . "/$plugin")) { + $pluginRealPath = realpath($plugin); + + if (!$pluginRealPath) { + throw new ModuleConfigException( + __CLASS__, + "Plugin file $plugin does not exist." + ); + } + + include_once $pluginRealPath; + + // Create a name for the external plugin in the format /. + $plugin = basename(dirname($pluginRealPath)) . '/' . basename($pluginRealPath); + } + + if ($isMultisite) { + // Network-activated plugins are stored in the format => . + $activePlugins[$plugin] = time(); + } else { + $activePlugins[] = $plugin; + } + } + + + // Update the active plugins to include all plugins, external or not. + if ($isMultisite) { + update_site_option('active_sitewide_plugins', $activePlugins); + } else { + update_option('active_plugins', array_values(array_unique($activePlugins))); + } + }, -100000); + } } diff --git a/src/WordPress/CodeExecution/ActivatePluginAction.php b/src/WordPress/CodeExecution/ActivatePluginAction.php index fcac7555f..7b45ead21 100644 --- a/src/WordPress/CodeExecution/ActivatePluginAction.php +++ b/src/WordPress/CodeExecution/ActivatePluginAction.php @@ -7,6 +7,7 @@ use lucatume\WPBrowser\WordPress\FileRequests\FileRequest; use lucatume\WPBrowser\WordPress\InstallationException; use WP_Error; + use function activate_plugin; class ActivatePluginAction implements CodeExecutionActionInterface @@ -33,7 +34,13 @@ public function __construct( private function activatePlugin(string $plugin, bool $multisite, bool $silent = false): void { require_once ABSPATH . 'wp-admin/includes/plugin.php'; - $activated = activate_plugin($plugin, '', $multisite, $silent); + + if (file_exists(WP_PLUGIN_DIR . '/' . $plugin)) { + $activated = activate_plugin($plugin, '', $multisite, $silent); + } else { + [$activated, $plugin] = $this->activateExternalPlugin($plugin, $multisite, $silent); + } + $activatedString = $multisite ? 'network activated' : 'activated'; $message = "Plugin $plugin could not be $activatedString."; @@ -41,7 +48,7 @@ private function activatePlugin(string $plugin, bool $multisite, bool $silent = $message = $activated->get_error_message(); $data = $activated->get_error_data(); if ($data && is_string($data)) { - $message .= ": $data"; + $message = substr($message, 0, -1) . ": $data"; } throw new InstallationException(trim($message)); } @@ -53,6 +60,60 @@ private function activatePlugin(string $plugin, bool $multisite, bool $silent = } } + /** + * @return array{0: bool|WP_Error, 1: string} + */ + private function activateExternalPlugin( + string $plugin, + bool $multisite, + bool $silent = false + ): array { + ob_start(); + try { + $pluginRealpath = realpath($plugin); + + if (!$pluginRealpath) { + return [new \WP_Error('plugin_not_found', "Plugin file $plugin does not exist."), '']; + } + + // Get the plugin name in the `plugin/plugin-file.php` format. + $pluginWpName = basename(dirname($pluginRealpath)) . '/' . basename($pluginRealpath); + + include_once $pluginRealpath; + + if (!$silent) { + do_action('activate_plugin', $pluginWpName, $multisite); + $pluginNameForActivationHook = ltrim($pluginRealpath, '\\/'); + do_action("activate_{$pluginNameForActivationHook}", $multisite); + } + + $activePlugins = $multisite ? get_site_option('active_sitewide_plugins') : get_option('active_plugins'); + + if (!is_array($activePlugins)) { + $activePlugins = []; + } + + if ($multisite) { + // Network-activated plugins are stored in the format => . + $activePlugins[$pluginWpName] = time(); + update_site_option('active_sitewide_plugins', $activePlugins); + } else { + $activePlugins[] = $pluginWpName; + update_option('active_plugins', $activePlugins); + } + } catch (\Throwable $t) { + return [new \WP_Error('plugin_activation_failed', $t->getMessage()), '']; + } + + $output = ob_get_clean(); + + if ($output) { + return [new \WP_Error('plugin_activation_output', $output), $pluginWpName]; + } + + return [true, $pluginWpName]; + } + public function getClosure(): Closure { $request = $this->request; diff --git a/tests/_data/plugins/exploding-plugin/main.php b/tests/_data/plugins/exploding-plugin/main.php new file mode 100644 index 000000000..684cf9de1 --- /dev/null +++ b/tests/_data/plugins/exploding-plugin/main.php @@ -0,0 +1,10 @@ + $myPluginCode, + + 'var' => [ + 'wordpress' => [] + ], + 'vendor' => [ + 'acme' => [ + + ] + ] + ]); + $wpRootDir = $projectDir. '/var/wordpress'; + $installation = Installation::scaffold($wpRootDir); + $dbName = Random::dbName(); + $dbHost = Env::get('WORDPRESS_DB_HOST'); + $dbUser = Env::get('WORDPRESS_DB_USER'); + $dbPassword = Env::get('WORDPRESS_DB_PASSWORD'); + $installationDb = new MysqlDatabase($dbName, $dbUser, $dbPassword, $dbHost, 'wp_'); + // Copy WooCommerce from the main installation to a temporary directory. + $tmpDir = sys_get_temp_dir(); + $mainWPInstallationRootDir = Env::get('WORDPRESS_ROOT_DIR'); + if (!FS::recurseCopy( + $mainWPInstallationRootDir . '/wp-content/plugins/woocommerce', + $tmpDir . '/external-woocommerce' + )) { + throw new \RuntimeException('Could not copy plugin woocommerce'); + } + $externalAbsolutePathPluginDir = $tmpDir . '/external-woocommerce'; + $this->assertFileExists($externalAbsolutePathPluginDir . '/woocommerce.php'); + if(!FS::recurseCopy( + codecept_data_dir('plugins/some-external-plugin'), + $projectDir . '/vendor/acme/some-external-plugin' + )){ + throw new \RuntimeException('Could not copy plugin some-external-plugin'); + } + + $hash = md5(microtime()); + $externalExplodingPlugin = sys_get_temp_dir() . '/' . $hash . '/exploding-plugin'; + if(!(mkdir($externalExplodingPlugin, 0777, true) && is_dir($externalExplodingPlugin))){ + throw new \RuntimeException('Could not create exploding plugin directory'); + } + if(!copy(codecept_data_dir('plugins/exploding-plugin/main.php'),$externalExplodingPlugin . '/main.php' )){ + throw new \RuntimeException('Could not copy exploding plugin file'); + } + $testPluginFileContents = <<< PHP +config = [ + 'wpRootFolder' => $wpRootDir, + 'dbUrl' => $installationDb->getDbUrl(), + 'tablePrefix' => 'test_', + 'plugins' => [ + 'test.php', // From the WordPress installation plugins directory. + $externalAbsolutePathPluginDir . '/woocommerce.php', // Absolute path. + 'vendor/acme/some-external-plugin/some-plugin.php', // Relative path to the project root folder. + 'my-plugin.php' // Relative path to the project root folder, development plugin file. + ], + 'silentlyActivatePlugins' => [ + $externalExplodingPlugin . '/main.php' // Absolute path. + ] + ]; + + $wpLoader = $this->module(); + $projectDirname = basename($projectDir); + + $this->assertInIsolation( + static function () use ($wpLoader, $projectDir) { + chdir($projectDir); + $projectDirname = basename($projectDir); + + $wpLoader->_initialize(); + + Assert::assertEquals([ + 'test.php', + 'external-woocommerce/woocommerce.php', + 'some-external-plugin/some-plugin.php', + "$projectDirname/my-plugin.php", + 'exploding-plugin/main.php' + ], get_option('active_plugins')); + + // Test plugin from the WordPress installation plugins directory. + Assert::assertEquals('1', get_option('test_plugin_activated')); + Assert::assertTrue(function_exists('test_plugin_main')); + + // WooCommerce from the absolute path. + Assert::assertTrue(function_exists('wc_get_product')); + Assert::assertTrue(class_exists('WC_Product')); + $product = new \WC_Product(); + $product->set_name('Test Product'); + $product->set_price(10); + $product->set_status('publish'); + $product->save(); + Assert::assertInstanceOf(\WC_Product::class, $product); + Assert::assertInstanceOf(\WC_Product::class, wc_get_product($product->get_id())); + + // Some external plugin from the relative path. + Assert::assertTrue(function_exists('some_plugin_main')); + Assert::assertEquals('1', get_option('some_plugin_activated')); + + // My plugin from the relative path. + Assert::assertTrue(function_exists('my_plugin_main')); + Assert::assertEquals('1', get_option('my_plugin_activated')); + + // Exploding plugin from the absolute path. + Assert::assertTrue(function_exists('exploding_plugin_main')); + Assert::assertEquals('', get_option('exploding_plugin_activated')); + } + ); + } + + /** + * It should allow loading a plugin from an arbitrary path in multisite + * + * @test + */ + public function should_allow_loading_a_plugin_from_an_arbitrary_path_in_multisite(): void + { + $myPluginCode = <<< PHP + $myPluginCode, + + 'var' => [ + 'wordpress' => [] + ], + 'vendor' => [ + 'acme' => [ + + ] + ] + ]); + $wpRootDir = $projectDir. '/var/wordpress'; + $installation = Installation::scaffold($wpRootDir); + $dbName = Random::dbName(); + $dbHost = Env::get('WORDPRESS_DB_HOST'); + $dbUser = Env::get('WORDPRESS_DB_USER'); + $dbPassword = Env::get('WORDPRESS_DB_PASSWORD'); + $installationDb = new MysqlDatabase($dbName, $dbUser, $dbPassword, $dbHost, 'wp_'); + // Copy WooCommerce from the main installation to a temporary directory. + $tmpDir = sys_get_temp_dir(); + $mainWPInstallationRootDir = Env::get('WORDPRESS_ROOT_DIR'); + if (!FS::recurseCopy( + $mainWPInstallationRootDir . '/wp-content/plugins/woocommerce', + $tmpDir . '/external-woocommerce' + )) { + throw new \RuntimeException('Could not copy plugin woocommerce'); + } + $externalAbsolutePathPluginDir = $tmpDir . '/external-woocommerce'; + $this->assertFileExists($externalAbsolutePathPluginDir . '/woocommerce.php'); + if(!FS::recurseCopy( + codecept_data_dir('plugins/some-external-plugin'), + $projectDir . '/vendor/acme/some-external-plugin' + )){ + throw new \RuntimeException('Could not copy plugin some-external-plugin'); + } + + $hash = md5(microtime()); + $externalExplodingPlugin = sys_get_temp_dir() . '/' . $hash . '/exploding-plugin'; + if(!(mkdir($externalExplodingPlugin, 0777, true) && is_dir($externalExplodingPlugin))){ + throw new \RuntimeException('Could not create exploding plugin directory'); + } + if(!copy(codecept_data_dir('plugins/exploding-plugin/main.php'),$externalExplodingPlugin . '/main.php' )){ + throw new \RuntimeException('Could not copy exploding plugin file'); + } + $testPluginFileContents = <<< PHP +config = [ + 'multisite' => true, + 'wpRootFolder' => $wpRootDir, + 'dbUrl' => $installationDb->getDbUrl(), + 'tablePrefix' => 'test_', + 'plugins' => [ + 'test.php', // From the WordPress installation plugins directory. + $externalAbsolutePathPluginDir . '/woocommerce.php', // Absolute path. + 'vendor/acme/some-external-plugin/some-plugin.php', // Relative path to the project root folder. + 'my-plugin.php' // Relative path to the project root folder, development plugin file. + ], + 'silentlyActivatePlugins' => [ + $externalExplodingPlugin . '/main.php' // Absolute path. + ] + ]; + + $wpLoader = $this->module(); + $projectDirname = basename($projectDir); + + $this->assertInIsolation( + static function () use ($wpLoader, $projectDir) { + chdir($projectDir); + $projectDirname = basename($projectDir); + + $wpLoader->_initialize(); + + Assert::assertEquals([ + 'test.php', + 'external-woocommerce/woocommerce.php', + 'some-external-plugin/some-plugin.php', + "$projectDirname/my-plugin.php", + 'exploding-plugin/main.php' + ], array_keys(get_site_option('active_sitewide_plugins'))); + + // Test plugin from the WordPress installation plugins directory. + Assert::assertEquals('1', get_option('test_plugin_activated')); + Assert::assertTrue(function_exists('test_plugin_main')); + + // WooCommerce from the absolute path. + Assert::assertTrue(function_exists('wc_get_product')); + Assert::assertTrue(class_exists('WC_Product')); + $product = new \WC_Product(); + $product->set_name('Test Product'); + $product->set_price(10); + $product->set_status('publish'); + $product->save(); + Assert::assertInstanceOf(\WC_Product::class, $product); + Assert::assertInstanceOf(\WC_Product::class, wc_get_product($product->get_id())); + + // Some external plugin from the relative path. + Assert::assertTrue(function_exists('some_plugin_main')); + Assert::assertEquals('1', get_option('some_plugin_activated')); + + // My plugin from the relative path. + Assert::assertTrue(function_exists('my_plugin_main')); + Assert::assertEquals('1', get_option('my_plugin_activated')); + + // Exploding plugin from the absolute path. + Assert::assertTrue(function_exists('exploding_plugin_main')); + Assert::assertEquals('', get_option('exploding_plugin_activated')); + } + ); + } }