From b44c44a2fda64d8f2f82469ec7dc2fdb10174dd1 Mon Sep 17 00:00:00 2001 From: "Md. Ashraful" Date: Fri, 18 Sep 2026 16:18:42 +0600 Subject: [PATCH 1/2] Add Extension class for WordPress plugin lifecycle management Provides install(), is_installed(), is_active(), activate(), deactivate(), and remove() so consuming plugins (Kirki, Kirki Ecommerce, GrowFund) don't each reimplement plugin install/activate against WordPress core's upgrader classes. install() now lets the caller control ZIP overwrite behavior and refuses to reactivate a plugin found already active after a reinstall. --- src/Wordpress/Extension.php | 196 ++++++++++++++++++++++ tests/Support/StubsWordPressFunctions.php | 95 +++++++++++ tests/Unit/Wordpress/ExtensionTest.php | 189 +++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 src/Wordpress/Extension.php create mode 100644 tests/Unit/Wordpress/ExtensionTest.php diff --git a/src/Wordpress/Extension.php b/src/Wordpress/Extension.php new file mode 100644 index 0000000..d1dd53c --- /dev/null +++ b/src/Wordpress/Extension.php @@ -0,0 +1,196 @@ +install($url, ['overwrite_package' => $overwrite]); + + throw_if(empty($result), 'Plugin installation failed.'); + + if (is_wp_error($result)) { + throw_anyway($result->get_error_message()); + } + + if (!$activate) { + return true; + } + + $plugin_path = $upgrader->plugin_info(); + + throw_if(!$plugin_path, 'Could not determine plugin path.'); + + throw_if(static::is_active($plugin_path), 'Plugin is already active.'); + + return static::activate($plugin_path); + } + + /** + * Check whether a plugin is installed. + * + * @param string $plugin_basename The plugin basename, e.g. "plugin-dir/plugin-file.php". + * + * @return bool + * + * @since 1.0.0 + */ + public static function is_installed(string $plugin_basename) + { + static::load_plugin_functions(); + + return array_key_exists($plugin_basename, get_plugins()); + } + + /** + * Check whether a plugin is active. + * + * @param string $plugin_basename The plugin basename, e.g. "plugin-dir/plugin-file.php". + * + * @return bool + * + * @since 1.0.0 + */ + public static function is_active(string $plugin_basename) + { + static::load_plugin_functions(); + + return is_plugin_active($plugin_basename); + } + + /** + * Activate an installed plugin. + * + * @param string $plugin_basename The plugin basename, e.g. "plugin-dir/plugin-file.php". + * + * @return bool + * + * @throws Exception If activation fails. + * + * @since 1.0.0 + */ + public static function activate(string $plugin_basename) + { + static::load_plugin_functions(); + + $result = activate_plugin($plugin_basename); + + if (is_wp_error($result)) { + throw_anyway($result->get_error_message()); + } + + return true; + } + + /** + * Deactivate an active plugin. + * + * @param string $plugin_basename The plugin basename, e.g. "plugin-dir/plugin-file.php". + * + * @return bool + * + * @since 1.0.0 + */ + public static function deactivate(string $plugin_basename) + { + static::load_plugin_functions(); + + deactivate_plugins($plugin_basename); + + return true; + } + + /** + * Remove an installed plugin's files. + * + * Refuses to remove a currently active plugin rather than deactivating it as a side effect — + * callers must deactivate() first. + * + * @param string $plugin_basename The plugin basename, e.g. "plugin-dir/plugin-file.php". + * + * @return bool + * + * @throws Exception If the plugin is active, or removal fails. + * + * @since 1.0.0 + */ + public static function remove(string $plugin_basename) + { + throw_if(static::is_active($plugin_basename), 'Cannot remove an active plugin. Deactivate it first.'); + + static::load_plugin_functions(); + + $result = delete_plugins([$plugin_basename]); + + if (is_wp_error($result)) { + throw_anyway($result->get_error_message()); + } + + return true; + } + + /** + * Load WordPress's plugin management functions if they are not already available. + * + * These (get_plugins(), is_plugin_active(), activate_plugin(), deactivate_plugins(), and + * delete_plugins()) are only autoloaded within the wp-admin request lifecycle. + * + * @return void + * + * @since 1.0.0 + */ + protected static function load_plugin_functions() + { + if (!function_exists('get_plugins')) { + include_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + } +} diff --git a/tests/Support/StubsWordPressFunctions.php b/tests/Support/StubsWordPressFunctions.php index eb97ce7..52dd8f4 100644 --- a/tests/Support/StubsWordPressFunctions.php +++ b/tests/Support/StubsWordPressFunctions.php @@ -858,3 +858,98 @@ function sanitize_file_name($filename) return trim($filename, '.-_'); } } + +if (!function_exists('is_wp_error')) { + function is_wp_error($thing) + { + return $thing instanceof WP_Error; + } +} + +if (!function_exists('get_plugins')) { + function get_plugins() + { + return $GLOBALS['framework_test_plugins'] ?? []; + } +} + +if (!function_exists('is_plugin_active')) { + function is_plugin_active($plugin) + { + return in_array($plugin, $GLOBALS['framework_test_active_plugins'] ?? [], true); + } +} + +if (!function_exists('activate_plugin')) { + function activate_plugin($plugin, $redirect = '', $network_wide = false, $silent = false) + { + $error = $GLOBALS['framework_test_activate_plugin_error'] ?? null; + + if ($error instanceof WP_Error) { + return $error; + } + + $GLOBALS['framework_test_active_plugins'][] = $plugin; + + return null; + } +} + +if (!function_exists('deactivate_plugins')) { + function deactivate_plugins($plugins, $silent = false, $network_wide = null) + { + $plugins = (array) $plugins; + + $GLOBALS['framework_test_active_plugins'] = array_values(array_diff( + $GLOBALS['framework_test_active_plugins'] ?? [], + $plugins + )); + } +} + +if (!function_exists('delete_plugins')) { + function delete_plugins($plugins) + { + $error = $GLOBALS['framework_test_delete_plugins_error'] ?? null; + + if ($error instanceof WP_Error) { + return $error; + } + + foreach ($plugins as $plugin) { + unset($GLOBALS['framework_test_plugins'][$plugin]); + } + + return true; + } +} + +if (!class_exists('Plugin_Upgrader')) { + class Plugin_Upgrader + { + public function __construct($skin = null) + { + } + + public function install($package, $args = []) + { + $GLOBALS['framework_test_plugin_upgrader_install_args'] = $args; + + return $GLOBALS['framework_test_plugin_upgrader_install_result'] ?? true; + } + + public function plugin_info() + { + return $GLOBALS['framework_test_plugin_upgrader_plugin_info'] ?? false; + } + } +} + +if (!class_exists('Automatic_Upgrader_Skin')) { + class Automatic_Upgrader_Skin + { + public function __construct($args = []) + { + } + } +} diff --git a/tests/Unit/Wordpress/ExtensionTest.php b/tests/Unit/Wordpress/ExtensionTest.php new file mode 100644 index 0000000..2c69926 --- /dev/null +++ b/tests/Unit/Wordpress/ExtensionTest.php @@ -0,0 +1,189 @@ +reset_plugin_globals(); + } + + protected function tearDown(): void + { + $this->reset_plugin_globals(); + + parent::tearDown(); + } + + protected function reset_plugin_globals(): void + { + $GLOBALS['framework_test_plugins'] = []; + $GLOBALS['framework_test_active_plugins'] = []; + $GLOBALS['framework_test_activate_plugin_error'] = null; + $GLOBALS['framework_test_delete_plugins_error'] = null; + $GLOBALS['framework_test_plugin_upgrader_install_result'] = true; + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = false; + } + + public function test_install_throws_for_empty_url(): void + { + $this->expectException(Exception::class); + + Extension::install(''); + } + + public function test_install_installs_without_activating(): void + { + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = 'sample/sample.php'; + + $result = Extension::install('https://example.com/plugin.zip', false); + + $this->assertTrue($result); + $this->assertFalse(Extension::is_active('sample/sample.php')); + } + + public function test_install_overwrites_by_default(): void + { + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = 'sample/sample.php'; + + Extension::install('https://example.com/plugin.zip', false); + + $this->assertTrue($GLOBALS['framework_test_plugin_upgrader_install_args']['overwrite_package']); + } + + public function test_install_can_disable_overwrite(): void + { + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = 'sample/sample.php'; + + Extension::install('https://example.com/plugin.zip', false, false); + + $this->assertFalse($GLOBALS['framework_test_plugin_upgrader_install_args']['overwrite_package']); + } + + public function test_install_installs_and_activates_by_default(): void + { + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = 'sample/sample.php'; + + $result = Extension::install('https://example.com/plugin.zip'); + + $this->assertTrue($result); + $this->assertTrue(Extension::is_active('sample/sample.php')); + } + + public function test_install_throws_when_installer_returns_empty_result(): void + { + $GLOBALS['framework_test_plugin_upgrader_install_result'] = false; + + $this->expectException(Exception::class); + + Extension::install('https://example.com/plugin.zip'); + } + + public function test_install_throws_when_installer_returns_wp_error(): void + { + $GLOBALS['framework_test_plugin_upgrader_install_result'] = new WP_Error('install_failed', 'Install failed.'); + + $this->expectExceptionMessage('Install failed.'); + + Extension::install('https://example.com/plugin.zip'); + } + + public function test_install_throws_instead_of_reactivating_an_already_active_plugin(): void + { + $GLOBALS['framework_test_plugin_upgrader_plugin_info'] = 'sample/sample.php'; + $GLOBALS['framework_test_active_plugins'] = ['sample/sample.php']; + + $this->expectException(Exception::class); + + Extension::install('https://example.com/plugin.zip'); + } + + public function test_is_installed_returns_true_for_installed_plugin(): void + { + $GLOBALS['framework_test_plugins'] = ['sample/sample.php' => ['Name' => 'Sample']]; + + $this->assertTrue(Extension::is_installed('sample/sample.php')); + } + + public function test_is_installed_returns_false_for_missing_plugin(): void + { + $this->assertFalse(Extension::is_installed('sample/sample.php')); + } + + public function test_is_active_returns_true_for_active_plugin(): void + { + $GLOBALS['framework_test_active_plugins'] = ['sample/sample.php']; + + $this->assertTrue(Extension::is_active('sample/sample.php')); + } + + public function test_is_active_returns_false_for_inactive_plugin(): void + { + $this->assertFalse(Extension::is_active('sample/sample.php')); + } + + public function test_activate_returns_true_on_success(): void + { + $result = Extension::activate('sample/sample.php'); + + $this->assertTrue($result); + $this->assertTrue(Extension::is_active('sample/sample.php')); + } + + public function test_activate_throws_on_wp_error(): void + { + $GLOBALS['framework_test_activate_plugin_error'] = new WP_Error('activate_failed', 'Activation failed.'); + + $this->expectExceptionMessage('Activation failed.'); + + Extension::activate('sample/sample.php'); + } + + public function test_deactivate_removes_plugin_from_active_list(): void + { + $GLOBALS['framework_test_active_plugins'] = ['sample/sample.php']; + + $result = Extension::deactivate('sample/sample.php'); + + $this->assertTrue($result); + $this->assertFalse(Extension::is_active('sample/sample.php')); + } + + public function test_remove_deletes_installed_inactive_plugin(): void + { + $GLOBALS['framework_test_plugins'] = ['sample/sample.php' => ['Name' => 'Sample']]; + + $result = Extension::remove('sample/sample.php'); + + $this->assertTrue($result); + $this->assertFalse(Extension::is_installed('sample/sample.php')); + } + + public function test_remove_throws_for_active_plugin(): void + { + $GLOBALS['framework_test_plugins'] = ['sample/sample.php' => ['Name' => 'Sample']]; + $GLOBALS['framework_test_active_plugins'] = ['sample/sample.php']; + + $this->expectException(Exception::class); + + Extension::remove('sample/sample.php'); + } + + public function test_remove_throws_on_wp_error(): void + { + $GLOBALS['framework_test_plugins'] = ['sample/sample.php' => ['Name' => 'Sample']]; + $GLOBALS['framework_test_delete_plugins_error'] = new WP_Error('delete_failed', 'Removal failed.'); + + $this->expectExceptionMessage('Removal failed.'); + + Extension::remove('sample/sample.php'); + } +} From 7dde4a78e6262a259b0d20212f1ffd1ac952e0ee Mon Sep 17 00:00:00 2001 From: "Md. Ashraful" Date: Fri, 18 Sep 2026 16:35:25 +0600 Subject: [PATCH 2/2] Remove redundant check for active plugin before activation --- src/Wordpress/Extension.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Wordpress/Extension.php b/src/Wordpress/Extension.php index d1dd53c..28ee266 100644 --- a/src/Wordpress/Extension.php +++ b/src/Wordpress/Extension.php @@ -69,8 +69,6 @@ public static function install(string $url, bool $activate = true, bool $overwri throw_if(!$plugin_path, 'Could not determine plugin path.'); - throw_if(static::is_active($plugin_path), 'Plugin is already active.'); - return static::activate($plugin_path); }