From 101749fa19ddc0c52885e7fdd1f7d78724bcc40b Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 21 Oct 2026 09:27:49 +0600 Subject: [PATCH 1/2] Tests: Add unit test coverage for delete_plugins(). --- .../admin/includes/plugin/deletePlugins.php | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 tests/phpunit/tests/admin/includes/plugin/deletePlugins.php diff --git a/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php b/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php new file mode 100644 index 0000000000000..b4955f3664551 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php @@ -0,0 +1,299 @@ +had_filesystem = array_key_exists( 'wp_filesystem', $GLOBALS ); + $this->original_filesystem = $this->had_filesystem ? $GLOBALS['wp_filesystem'] : null; + + self::$uninstalled = array(); + delete_site_transient( 'update_plugins' ); + } + + /** + * Removes what the test created and restores the filesystem global. + */ + public function tear_down() { + foreach ( array_reverse( $this->created ) as $path ) { + if ( is_file( $path ) ) { + unlink( $path ); + } elseif ( is_dir( $path ) ) { + $this->rmdir( $path ); + $this->delete_folders( $path ); + } + } + + if ( $this->had_filesystem ) { + $GLOBALS['wp_filesystem'] = $this->original_filesystem; + } else { + unset( $GLOBALS['wp_filesystem'] ); + } + + wp_cache_delete( 'plugins', 'plugins' ); + delete_site_transient( 'update_plugins' ); + + parent::tear_down(); + } + + /** + * Tests that nothing is deleted without plugins. + * + * @ticket 65819 + * + * @dataProvider data_no_plugins + * + * @param mixed $plugins The plugins to delete. + */ + public function test_should_return_false_without_plugins( $plugins ) { + $this->assertFalse( delete_plugins( $plugins ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_no_plugins() { + return array( + 'empty array' => array( array() ), + 'null' => array( null ), + ); + } + + /** + * Tests that a plugin directory is deleted and the delete hooks fire. + * + * @ticket 65819 + */ + public function test_should_delete_plugin_directory_and_fire_hooks() { + $plugin = $this->create_plugin_directory(); + + $delete = new MockAction(); + $deleted = new MockAction(); + add_action( 'delete_plugin', array( $delete, 'action' ) ); + add_action( 'deleted_plugin', array( $deleted, 'action' ), 10, 2 ); + + $this->assertTrue( delete_plugins( array( $plugin ) ), 'The plugin should be deleted.' ); + $this->assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . dirname( $plugin ), 'The plugin directory should be removed.' ); + $this->assertSame( array( array( $plugin ) ), $delete->get_args(), 'delete_plugin should receive the plugin.' ); + $this->assertSame( array( array( $plugin, true ) ), $deleted->get_args(), 'deleted_plugin should receive the plugin and the result.' ); + } + + /** + * Tests that only the file of a single-file plugin is deleted. + * + * @ticket 65819 + */ + public function test_should_delete_only_file_of_single_file_plugin() { + $plugin = 'delete-test-single-' . ++self::$counter . '.php'; + $this->create_file( WP_PLUGIN_DIR . '/' . $plugin, "assertTrue( delete_plugins( array( $plugin ) ), 'The plugin should be deleted.' ); + $this->assertFileDoesNotExist( WP_PLUGIN_DIR . '/' . $plugin, 'The plugin file should be removed.' ); + $this->assertFileExists( WP_PLUGIN_DIR . '/hello.php', 'Other plugins should be kept.' ); + } + + /** + * Tests that the uninstall callback of a plugin runs before it is deleted. + * + * @ticket 65819 + */ + public function test_should_run_uninstall_callback() { + $plugin = $this->create_plugin_directory(); + update_option( 'uninstall_plugins', array( $plugin => array( __CLASS__, 'record_uninstall' ) ) ); + + $this->assertTrue( delete_plugins( array( $plugin ) ), 'The plugin should be deleted.' ); + $this->assertSame( array( $plugin ), self::$uninstalled, 'The uninstall callback should run.' ); + } + + /** + * Tests that deleted plugins are removed from the available updates. + * + * @ticket 65819 + */ + public function test_should_remove_deleted_plugins_from_updates() { + $deleted = $this->create_plugin_directory(); + $missing = $this->create_undeletable_plugin(); + + set_site_transient( + 'update_plugins', + (object) array( + 'response' => array( + $deleted => (object) array( 'slug' => dirname( $deleted ) ), + $missing => (object) array( 'slug' => 'missing-plugin' ), + 'other/other.php' => (object) array( 'slug' => 'other' ), + ), + ) + ); + + delete_plugins( array( $deleted, $missing ) ); + + $this->assertSame( array( $missing, 'other/other.php' ), array_keys( get_site_transient( 'update_plugins' )->response ) ); + } + + /** + * Tests that the translation files of a deleted plugin are removed. + * + * @ticket 65819 + */ + public function test_should_delete_translation_files() { + $plugin = $this->create_plugin_directory(); + $slug = dirname( $plugin ); + $dir = WP_LANG_DIR . '/plugins/'; + + $po = "msgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: Test\\n\"\n\"Language: de_DE\\n\"\n"; + foreach ( array( '.po', '.mo', '.l10n.php', '-0123456789abcdef.json' ) as $suffix ) { + $this->create_file( "{$dir}{$slug}-de_DE{$suffix}", '.po' === $suffix ? $po : 'x' ); + } + + $this->assertTrue( delete_plugins( array( $plugin ) ), 'The plugin should be deleted.' ); + + foreach ( array( '.po', '.mo', '.l10n.php', '-0123456789abcdef.json' ) as $suffix ) { + $this->assertFileDoesNotExist( "{$dir}{$slug}-de_DE{$suffix}", "The {$suffix} translation file should be removed." ); + } + $this->assertFileExists( $dir . 'internationalized-plugin-de_DE.mo', 'Translations of other plugins should be kept.' ); + } + + /** + * Tests the error when a plugin cannot be removed. + * + * @ticket 65819 + */ + public function test_should_report_plugin_that_could_not_be_removed() { + $plugin = $this->create_undeletable_plugin(); + + $deleted = new MockAction(); + add_action( 'deleted_plugin', array( $deleted, 'action' ), 10, 2 ); + + $result = delete_plugins( array( $plugin ) ); + + $this->assertWPError( $result, 'An error should be returned.' ); + $this->assertSame( 'could_not_remove_plugin', $result->get_error_code(), 'The error code should match.' ); + $this->assertSame( "Could not fully remove the plugin {$plugin}.", $result->get_error_message(), 'The error message should name the plugin.' ); + $this->assertSame( array( array( $plugin, false ) ), $deleted->get_args(), 'deleted_plugin should receive false.' ); + } + + /** + * Tests the error when several plugins cannot be removed, while the others are deleted. + * + * @ticket 65819 + */ + public function test_should_report_plugins_that_could_not_be_removed() { + $first = $this->create_undeletable_plugin(); + $plugin = $this->create_plugin_directory(); + $second = $this->create_undeletable_plugin(); + + $result = delete_plugins( array( $first, $plugin, $second ) ); + + $this->assertWPError( $result, 'An error should be returned.' ); + $this->assertSame( "Could not fully remove the plugins {$first}, {$second}.", $result->get_error_message(), 'The error message should list the plugins.' ); + $this->assertDirectoryDoesNotExist( WP_PLUGIN_DIR . '/' . dirname( $plugin ), 'The other plugin should be deleted.' ); + } + + /** + * Records that an uninstall callback ran. + */ + public static function record_uninstall() { + self::$uninstalled[] = current_action() ? substr( current_action(), strlen( 'uninstall_' ) ) : ''; + } + + /** + * Creates a plugin directory with a main file and a second file. + * + * @return string The plugin file. + */ + private function create_plugin_directory() { + $slug = 'delete-test-plugin-' . ++self::$counter; + $dir = WP_PLUGIN_DIR . '/' . $slug; + + $this->assertDirectoryDoesNotExist( $dir, 'The test plugin directory should not exist yet.' ); + $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); + $this->created[] = $dir; + + $this->create_file( "{$dir}/{$slug}.php", "create_file( "{$dir}/readme.txt", 'Readme.' ); + + wp_cache_delete( 'plugins', 'plugins' ); + + return "{$slug}/{$slug}.php"; + } + + /** + * Creates a single-file plugin that cannot be deleted: a directory with a file in it, + * which a non-recursive delete cannot remove. + * + * @return string The plugin file. + */ + private function create_undeletable_plugin() { + $plugin = 'delete-test-undeletable-' . ++self::$counter . '.php'; + $dir = WP_PLUGIN_DIR . '/' . $plugin; + + $this->assertDirectoryDoesNotExist( $dir, 'The test directory should not exist yet.' ); + $this->assertTrue( mkdir( $dir ), 'The test directory should be created.' ); + $this->created[] = $dir; + + $this->create_file( $dir . '/keep.txt', 'Keep.' ); + + return $plugin; + } + + /** + * Creates a file and registers it for cleanup. + * + * @param string $path The file path. + * @param string $contents The file contents. + */ + private function create_file( $path, $contents ) { + $this->assertFileDoesNotExist( $path, 'The file should not exist yet.' ); + $this->assertNotFalse( file_put_contents( $path, $contents ), 'The file should be written.' ); + $this->created[] = $path; + } +} From d7805bfe98a67bbd5e46fa85af7c29b6b2448543 Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 21 Oct 2026 12:21:08 +0600 Subject: [PATCH 2/2] Tests: Remove leftover test plugins before creating them in the delete_plugins() tests. --- .../admin/includes/plugin/deletePlugins.php | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php b/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php index b4955f3664551..deb4e61ed9017 100644 --- a/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php +++ b/tests/phpunit/tests/admin/includes/plugin/deletePlugins.php @@ -63,12 +63,7 @@ public function set_up() { */ public function tear_down() { foreach ( array_reverse( $this->created ) as $path ) { - if ( is_file( $path ) ) { - unlink( $path ); - } elseif ( is_dir( $path ) ) { - $this->rmdir( $path ); - $this->delete_folders( $path ); - } + $this->remove_path( $path ); } if ( $this->had_filesystem ) { @@ -254,7 +249,7 @@ private function create_plugin_directory() { $slug = 'delete-test-plugin-' . ++self::$counter; $dir = WP_PLUGIN_DIR . '/' . $slug; - $this->assertDirectoryDoesNotExist( $dir, 'The test plugin directory should not exist yet.' ); + $this->remove_path( $dir ); $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); $this->created[] = $dir; @@ -276,7 +271,7 @@ private function create_undeletable_plugin() { $plugin = 'delete-test-undeletable-' . ++self::$counter . '.php'; $dir = WP_PLUGIN_DIR . '/' . $plugin; - $this->assertDirectoryDoesNotExist( $dir, 'The test directory should not exist yet.' ); + $this->remove_path( $dir ); $this->assertTrue( mkdir( $dir ), 'The test directory should be created.' ); $this->created[] = $dir; @@ -292,8 +287,22 @@ private function create_undeletable_plugin() { * @param string $contents The file contents. */ private function create_file( $path, $contents ) { - $this->assertFileDoesNotExist( $path, 'The file should not exist yet.' ); + $this->remove_path( $path ); $this->assertNotFalse( file_put_contents( $path, $contents ), 'The file should be written.' ); $this->created[] = $path; } + + /** + * Removes a file or directory, including one left over by an interrupted run. + * + * @param string $path The file or directory path. + */ + private function remove_path( $path ) { + if ( is_file( $path ) ) { + unlink( $path ); + } elseif ( is_dir( $path ) ) { + $this->rmdir( $path ); + $this->delete_folders( $path ); + } + } }