From 699949c35ae05dc82b87efc831b8c7b1c4f8b3f5 Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 21 Oct 2026 09:18:55 +0600 Subject: [PATCH 1/5] Tests: Add unit test coverage for the row meta in WP_Plugins_List_Table::single_row(). --- .../tests/admin/wpPluginsListTableRowMeta.php | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php diff --git a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php new file mode 100644 index 0000000000000..5bfc9d53dbd42 --- /dev/null +++ b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php @@ -0,0 +1,315 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$activator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$activator_id )->add_cap( 'activate_plugins' ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + /** + * Revokes the super admin grant before the fixture users are deleted. + */ + public static function wpTearDownAfterClass() { + if ( is_multisite() ) { + revoke_super_admin( self::$admin_id ); + } + } + + /** + * Sets up the Plugins screen and the list table globals. + */ + public function set_up() { + parent::set_up(); + + foreach ( array( 'status', 'page', 's', 'totals', 'current_screen' ) as $name ) { + $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; + } + + set_current_screen( 'plugins' ); + + $GLOBALS['status'] = 'all'; + $GLOBALS['page'] = 1; + $GLOBALS['s'] = ''; + $GLOBALS['totals'] = array(); + + $this->filter_args = null; + $this->slug = 'row-meta-plugin-' . ++self::$counter; + + add_filter( 'plugin_row_meta', array( $this, 'capture_meta' ), PHP_INT_MAX, 4 ); + + wp_set_current_user( self::$admin_id ); + } + + /** + * Restores the globals and removes the test plugin. + */ + public function tear_down() { + foreach ( $this->original_globals as $name => $value ) { + $GLOBALS[ $name ] = $value; + } + + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + if ( is_dir( $dir ) ) { + unlink( $dir . '/' . $this->slug . '.php' ); + rmdir( $dir ); + } + + parent::tear_down(); + } + + /** + * Tests the version, linked author and View details link of a plugin from the directory. + * + * @ticket 65819 + */ + public function test_should_list_version_author_and_details_link() { + $this->assertSame( + array( + 'Version 1.2.3', + 'By Jane', + 'View details', + ), + $this->get_meta( + array( + 'Name' => 'My "Plugin"', + 'Version' => '1.2.3', + 'Author' => 'Jane', + 'AuthorURI' => 'https://example.org/jane/', + 'slug' => $this->slug, + ) + ) + ); + } + + /** + * Tests the author without a link and the plugin site link of a plugin that is not from the directory. + * + * @ticket 65819 + */ + public function test_should_link_plugin_site_without_directory_slug() { + $this->assertSame( + array( + 'Version 1.2.3', + 'By Jane', + 'Visit plugin site', + ), + $this->get_meta( + array( + 'Version' => '1.2.3', + 'Author' => 'Jane', + 'PluginURI' => 'https://example.org/plugin/?a=1&b=2', + ) + ) + ); + } + + /** + * Tests that a user who cannot install plugins gets the plugin site link instead of View details. + * + * @ticket 65819 + */ + public function test_should_link_plugin_site_for_user_without_install_capability() { + wp_set_current_user( self::$activator_id ); + + $this->assertSame( + array( 'Visit plugin site' ), + $this->get_meta( + array( + 'slug' => $this->slug, + 'PluginURI' => 'https://example.org/plugin/', + ) + ) + ); + } + + /** + * Tests that empty fields are left out. + * + * @ticket 65819 + * + * @dataProvider data_empty_values + * + * @param mixed $value The empty value of the version, author, author URI and plugin URI. + */ + public function test_should_leave_out_empty_fields( $value ) { + $this->assertSame( + array(), + $this->get_meta( + array( + 'Version' => $value, + 'Author' => $value, + 'AuthorURI' => 'https://example.org/jane/', + 'PluginURI' => $value, + ) + ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_empty_values() { + return array( + 'empty string' => array( '' ), + 'zero string' => array( '0' ), + ); + } + + /** + * Tests that the filter receives the plugin file, data and status, and its links are joined. + * + * @ticket 65819 + */ + public function test_should_filter_and_join_meta() { + add_filter( + 'plugin_row_meta', + static function ( $meta ) { + $meta[] = 'Docs'; + + return $meta; + } + ); + + $output = $this->render( array( 'Version' => '1.2.3' ) ); + + $this->assertSame( $this->get_file(), $this->filter_args[1], 'The filter should receive the plugin file.' ); + $this->assertSame( 'My Plugin', $this->filter_args[2]['Name'], 'The filter should receive the plugin data.' ); + $this->assertSame( 'all', $this->filter_args[3], 'The filter should receive the status.' ); + $this->assertStringContainsString( 'plugin-version-author-uri\'>Version 1.2.3 | Docs', $output, 'The links should be joined.' ); + } + + /** + * Captures the arguments of the plugin_row_meta filter. + * + * @param string[] $meta The row meta. + * @param string $plugin_file The plugin file. + * @param array $plugin_data The plugin data. + * @param string $status The plugin status. + * @return string[] The unchanged row meta. + */ + public function capture_meta( $meta, $plugin_file, $plugin_data, $status ) { + $this->filter_args = array( $meta, $plugin_file, $plugin_data, $status ); + + return $meta; + } + + /** + * Renders the row of the test plugin and returns its row meta. + * + * @param array $plugin_data The plugin data to add. + * @return string[] The row meta, before the plugin_row_meta filter. + */ + private function get_meta( $plugin_data ) { + $this->render( $plugin_data ); + + return $this->filter_args[0]; + } + + /** + * Renders the row of the test plugin. + * + * @param array $plugin_data The plugin data to add. + * @return string The row markup. + */ + private function render( $plugin_data ) { + // is_network_only_plugin() reads the plugin headers on multisite. + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); + $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', " 'plugins' ) ); + + return get_echo( + array( $table, 'single_row' ), + array( + array( + $this->get_file(), + array_merge( + array( + 'Name' => 'My Plugin', + 'Description' => '', + 'Version' => '', + 'Author' => '', + 'AuthorURI' => '', + 'PluginURI' => '', + ), + $plugin_data + ), + ), + ) + ); + } + + /** + * Returns the plugin file of the test plugin. + * + * @return string The plugin file. + */ + private function get_file() { + return "{$this->slug}/{$this->slug}.php"; + } +} From 4f70c2e63fa99a41fca99abe162187fe54629025 Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 21 Oct 2026 11:44:49 +0600 Subject: [PATCH 2/5] Tests: Remove a leftover test plugin before creating it in the WP_Plugins_List_Table row meta tests. --- .../tests/admin/wpPluginsListTableRowMeta.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php index 5bfc9d53dbd42..9f268e470cfb7 100644 --- a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php +++ b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php @@ -109,11 +109,7 @@ public function tear_down() { $GLOBALS[ $name ] = $value; } - $dir = WP_PLUGIN_DIR . '/' . $this->slug; - if ( is_dir( $dir ) ) { - unlink( $dir . '/' . $this->slug . '.php' ); - rmdir( $dir ); - } + $this->remove_plugin(); parent::tear_down(); } @@ -278,6 +274,7 @@ private function get_meta( $plugin_data ) { private function render( $plugin_data ) { // is_network_only_plugin() reads the plugin headers on multisite. $dir = WP_PLUGIN_DIR . '/' . $this->slug; + $this->remove_plugin(); $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', "slug}/{$this->slug}.php"; } + + /** + * Removes the test plugin, including one left over by an interrupted run. + */ + private function remove_plugin() { + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + + if ( is_dir( $dir ) ) { + $this->rmdir( $dir ); + rmdir( $dir ); + } + } } From 1b398c61563d126e613b191a7d4db88965f5cd99 Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Tue, 27 Oct 2026 18:17:25 -0700 Subject: [PATCH 3/5] Tests: Exercise plugin details link precedence in the existing fixture. --- tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php index 9f268e470cfb7..9ca4c23d017ac 100644 --- a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php +++ b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php @@ -133,6 +133,7 @@ public function test_should_list_version_author_and_details_link() { 'Version' => '1.2.3', 'Author' => 'Jane', 'AuthorURI' => 'https://example.org/jane/', + 'PluginURI' => 'https://example.org/plugin/', 'slug' => $this->slug, ) ) From a80dcb6811e078bf31171443e749dc334534665e Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 28 Oct 2026 18:12:46 +0600 Subject: [PATCH 4/5] Tests: Improve fixture isolation, URL handling, and empty AuthorURI checks in plugin row meta tests. - Use array_key_exists() to restore only genuinely set globals on tear_down(). - Use dynamic unique folder naming and delete_folders() to prevent test collisions. - Construct expected View details link dynamically using network_admin_url(). - Add unit test coverage for empty and '0' AuthorURI with non-empty Author. - Assert filter_args is an array before indexing. - Scope disk writes and WP_PLUGIN_DIR is_writable check to multisite only. Follow-up to #14603 per committer feedback. --- .../tests/admin/wpPluginsListTableRowMeta.php | 86 +++++++++++++++---- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php index 9ca4c23d017ac..c987c559a55bb 100644 --- a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php +++ b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php @@ -82,8 +82,10 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); - foreach ( array( 'status', 'page', 's', 'totals', 'current_screen' ) as $name ) { - $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; + foreach ( array( 'status', 's', 'totals' ) as $name ) { + if ( array_key_exists( $name, $GLOBALS ) ) { + $this->original_globals[ $name ] = $GLOBALS[ $name ]; + } } set_current_screen( 'plugins' ); @@ -94,7 +96,7 @@ public function set_up() { $GLOBALS['totals'] = array(); $this->filter_args = null; - $this->slug = 'row-meta-plugin-' . ++self::$counter; + $this->slug = 'row-meta-plugin-' . ++self::$counter . '-' . wp_generate_password( 6, false ); add_filter( 'plugin_row_meta', array( $this, 'capture_meta' ), PHP_INT_MAX, 4 ); @@ -105,8 +107,12 @@ public function set_up() { * Restores the globals and removes the test plugin. */ public function tear_down() { - foreach ( $this->original_globals as $name => $value ) { - $GLOBALS[ $name ] = $value; + foreach ( array( 'status', 's', 'totals' ) as $name ) { + if ( array_key_exists( $name, $this->original_globals ) ) { + $GLOBALS[ $name ] = $this->original_globals[ $name ]; + } else { + unset( $GLOBALS[ $name ] ); + } } $this->remove_plugin(); @@ -120,12 +126,18 @@ public function tear_down() { * @ticket 65819 */ public function test_should_list_version_author_and_details_link() { + $expected_details_url = esc_url( + network_admin_url( + 'plugin-install.php?tab=plugin-information&plugin=' . $this->slug . + '&TB_iframe=true&width=600&height=550' + ) + ); + $this->assertSame( array( 'Version 1.2.3', 'By Jane', - 'View details', + 'View details', ), $this->get_meta( array( @@ -216,6 +228,27 @@ public static function data_empty_values() { ); } + /** + * Tests that an empty or '0' author URI does not link the author. + * + * @ticket 65819 + * + * @dataProvider data_empty_values + * + * @param string $empty_uri Empty or '0' author URI. + */ + public function test_should_not_link_author_when_author_uri_is_empty( $empty_uri ) { + $this->assertSame( + array( 'By Jane' ), + $this->get_meta( + array( + 'Author' => 'Jane', + 'AuthorURI' => $empty_uri, + ) + ) + ); + } + /** * Tests that the filter receives the plugin file, data and status, and its links are joined. * @@ -233,6 +266,7 @@ static function ( $meta ) { $output = $this->render( array( 'Version' => '1.2.3' ) ); + $this->assertIsArray( $this->filter_args, 'The plugin_row_meta filter should run.' ); $this->assertSame( $this->get_file(), $this->filter_args[1], 'The filter should receive the plugin file.' ); $this->assertSame( 'My Plugin', $this->filter_args[2]['Name'], 'The filter should receive the plugin data.' ); $this->assertSame( 'all', $this->filter_args[3], 'The filter should receive the status.' ); @@ -263,6 +297,8 @@ public function capture_meta( $meta, $plugin_file, $plugin_data, $status ) { private function get_meta( $plugin_data ) { $this->render( $plugin_data ); + $this->assertIsArray( $this->filter_args, 'The plugin_row_meta filter should run.' ); + return $this->filter_args[0]; } @@ -273,11 +309,17 @@ private function get_meta( $plugin_data ) { * @return string The row markup. */ private function render( $plugin_data ) { - // is_network_only_plugin() reads the plugin headers on multisite. - $dir = WP_PLUGIN_DIR . '/' . $this->slug; - $this->remove_plugin(); - $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); - $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', "markTestSkipped( 'WP_PLUGIN_DIR is not writable.' ); + } + + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + $this->remove_plugin(); + $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); + $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', " 'plugins' ) ); @@ -312,14 +354,24 @@ private function get_file() { } /** - * Removes the test plugin, including one left over by an interrupted run. + * Removes the test plugin, including any left over by an interrupted run. */ private function remove_plugin() { - $dir = WP_PLUGIN_DIR . '/' . $this->slug; + if ( $this->slug ) { + $dir = WP_PLUGIN_DIR . '/' . $this->slug; - if ( is_dir( $dir ) ) { - $this->rmdir( $dir ); - rmdir( $dir ); + if ( is_dir( $dir ) ) { + $this->rmdir( $dir ); + $this->delete_folders( $dir ); + } + } + + $dirs = glob( WP_PLUGIN_DIR . '/row-meta-plugin-*', GLOB_ONLYDIR ); + if ( is_array( $dirs ) ) { + foreach ( $dirs as $leftover ) { + $this->rmdir( $leftover ); + $this->delete_folders( $leftover ); + } } } } From 81c388155ee6ae87e95ebd14c7adadb3e7473072 Mon Sep 17 00:00:00 2001 From: Huzaifa Al Mesbah Date: Wed, 28 Oct 2026 20:43:18 +0600 Subject: [PATCH 5/5] Tests: Restrict temporary plugin cleanup to the active test directory and multisite in wpPluginsListTableRowMeta.php. --- .../tests/admin/wpPluginsListTableRowMeta.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php index c987c559a55bb..8ad060e649454 100644 --- a/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php +++ b/tests/phpunit/tests/admin/wpPluginsListTableRowMeta.php @@ -354,10 +354,10 @@ private function get_file() { } /** - * Removes the test plugin, including any left over by an interrupted run. + * Removes the test plugin. */ private function remove_plugin() { - if ( $this->slug ) { + if ( is_multisite() && $this->slug ) { $dir = WP_PLUGIN_DIR . '/' . $this->slug; if ( is_dir( $dir ) ) { @@ -365,13 +365,5 @@ private function remove_plugin() { $this->delete_folders( $dir ); } } - - $dirs = glob( WP_PLUGIN_DIR . '/row-meta-plugin-*', GLOB_ONLYDIR ); - if ( is_array( $dirs ) ) { - foreach ( $dirs as $leftover ) { - $this->rmdir( $leftover ); - $this->delete_folders( $leftover ); - } - } } }