Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support arbitrary paths for plugins #725

Merged
merged 1 commit into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading