Skip to content

Commit

Permalink
feat(WPLoader) support arbitrary paths for plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
lucatume committed May 21, 2024
1 parent c291dd8 commit c5c95de
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 22 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 15 additions & 11 deletions docs/modules/WPLoader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand All @@ -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
```
Expand Down
84 changes: 79 additions & 5 deletions src/Module/WPLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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")
)
);
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 <directory>/<file.php>.
$plugin = basename(dirname($pluginRealPath)) . '/' . basename($pluginRealPath);
}

if ($isMultisite) {
// Network-activated plugins are stored in the format <plugins_name> => <timestamp>.
$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);
}
}
65 changes: 63 additions & 2 deletions src/WordPress/CodeExecution/ActivatePluginAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,15 +34,21 @@ 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.";

if ($activated instanceof WP_Error) {
$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));
}
Expand All @@ -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 <plugins_name> => <timestamp>.
$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;
Expand Down
10 changes: 10 additions & 0 deletions tests/_data/plugins/exploding-plugin/main.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
/** Plugin Name: Exploding Plugin */

function exploding_plugin_main(){}

register_activation_hook( __FILE__, 'exploding_plugin_main_activation' );
function exploding_plugin_main_activation(){
update_option('exploding_plugin_activated', 1);
throw new \RuntimeException('Boom');
}
9 changes: 9 additions & 0 deletions tests/_data/plugins/some-external-plugin/some-plugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
/** Plugin Name: Some Plugin */

function some_plugin_main(){}

register_activation_hook( __FILE__, 'some_plugin_activation' );
function some_plugin_activation(){
update_option('some_plugin_activated', 1);
}
2 changes: 0 additions & 2 deletions tests/_support/Traits/LoopIsolation.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
namespace lucatume\WPBrowser\Tests\Traits;

use Closure;
use Codeception\Codecept;
use Codeception\Util\Debug;
use lucatume\WPBrowser\Process\Loop;
use lucatume\WPBrowser\Process\ProcessException;
use lucatume\WPBrowser\Process\WorkerException;
use lucatume\WPBrowser\Utils\Codeception;
use lucatume\WPBrowser\Utils\Property;
use ReflectionObject;
use Throwable;
Expand Down
Loading

0 comments on commit c5c95de

Please sign in to comment.