From 6e99d810852f043e767d6a10fae1af9e8c13ba39 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 21 May 2024 13:14:08 +0200 Subject: [PATCH] feat(WPLoader) support arbitrary paths --- src/Module/WPLoader.php | 88 +++++- .../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 | 287 +++++++++++++++++- 6 files changed, 450 insertions(+), 11 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/src/Module/WPLoader.php b/src/Module/WPLoader.php index 908630ffe..55d8cf41d 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -648,17 +648,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) { @@ -680,6 +680,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); @@ -1057,7 +1059,17 @@ 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 function (string $plugin) use ($pluginsDir) { + return is_file($pluginsDir . "/$plugin"); + } + ) + ); } /** @@ -1090,8 +1102,24 @@ 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 function (string $plugin) use ($pluginsDir) { + return 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 @@ -1106,4 +1134,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 63ae00854..fce9124f2 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 @@ -38,7 +39,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."; @@ -46,7 +53,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)); } @@ -58,6 +65,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 @@ +expectException(ModuleException::class); $this->expectExceptionMessage( - 'Failed to activate plugin some-plugin/some-plugin.php. Plugin file does not exist.' + 'Failed to activate plugin some-plugin/some-plugin.php. Plugin file some-plugin/some-plugin.php does not exist.' ); $wpLoader = $this->module(); @@ -1245,7 +1245,7 @@ public function should_throw_if_there_is_an_error_while_activating_a_plugin_in_m $this->expectException(ModuleException::class); $this->expectExceptionMessage( - 'Failed to activate plugin some-plugin/some-plugin.php. Plugin file does not exist.' + 'Failed to activate plugin some-plugin/some-plugin.php. Plugin file some-plugin/some-plugin.php does not exist.' ); $wpLoader = $this->module(); @@ -2739,4 +2739,287 @@ static function () use ($wpLoader) { } ); } + + /** + * It should allow loading a plugin from an arbitrary path + * + * @test + */ + public function should_allow_loading_a_plugin_from_an_arbitrary_path(): 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 = [ + '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')); + } + ); + } }