From 2d6e20eb2ac4e3b03c0e55984ebdf71e6e7817b6 Mon Sep 17 00:00:00 2001 From: Md Abul Bashar Date: Fri, 6 Nov 2026 21:01:45 +0600 Subject: [PATCH 1/3] Tests: Add unit tests for the functions that output the Add Plugins screen. See #66558. --- .../InstallScreenFunctions_Test.php | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php diff --git a/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php new file mode 100644 index 0000000000000..e5e3e3f2620e2 --- /dev/null +++ b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php @@ -0,0 +1,432 @@ +original['request'] = $_REQUEST; + $this->original['get'] = $_GET; + $this->original['wp_list_table'] = $GLOBALS['wp_list_table'] ?? null; + + $_REQUEST = array(); + $_GET = array(); + + $this->api_requests = array(); + $this->api_response = array( + 'seo' => array( + 'name' => 'SEO', + 'slug' => 'seo', + 'count' => 1, + ), + 'forms' => array( + 'name' => 'Contact Forms', + 'slug' => 'contact-forms', + 'count' => 25, + ), + ); + + add_filter( 'pre_http_request', array( $this, 'answer_api' ), 10, 3 ); + add_filter( 'wp_trigger_error_trigger_error', '__return_false' ); + } + + /** + * Restores the globals and the request data. + */ + public function tear_down() { + $_REQUEST = $this->original['request']; + $_GET = $this->original['get']; + + $GLOBALS['wp_list_table'] = $this->original['wp_list_table']; + + parent::tear_down(); + } + + /** + * Answers the requests to WordPress.org, and records them. + * + * @param false|array|WP_Error $preempt The response. + * @param array $args The arguments of the request. + * @param string $url The URL of the request. + * @return array|WP_Error The response. + */ + public function answer_api( $preempt, $args, $url ) { + parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + $this->api_requests[] = array( $query['action'] ?? '', $query['request'] ?? array() ); + + if ( is_wp_error( $this->api_response ) ) { + return $this->api_response; + } + + return array( + 'headers' => array(), + 'body' => wp_json_encode( $this->api_response ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); + } + + /** + * Tests that the popular tags come from the API. + * + * @ticket 66558 + * + * @covers ::install_popular_tags + */ + public function test_install_popular_tags_should_ask_the_api_for_the_hot_tags() { + $tags = install_popular_tags( array( 'number' => 5 ) ); + + $this->assertEquals( (object) $this->api_response, $tags ); + $this->assertCount( 1, $this->api_requests ); + $this->assertSame( 'hot_tags', $this->api_requests[0][0] ); + $this->assertSame( '5', $this->api_requests[0][1]['number'] ); + } + + /** + * Tests that the popular tags are cached for three hours. + * + * @ticket 66558 + * + * @covers ::install_popular_tags + */ + public function test_install_popular_tags_should_cache_the_tags_by_arguments() { + $args = array( 'number' => 5 ); + + install_popular_tags( $args ); + + $this->assertEquals( (object) $this->api_response, get_site_transient( 'poptags_' . md5( serialize( $args ) ) ), 'The tags should be cached.' ); + + $expires = (int) get_site_option( '_site_transient_timeout_poptags_' . md5( serialize( $args ) ) ); + + $this->assertEqualsWithDelta( time() + ( 3 * HOUR_IN_SECONDS ), $expires, 10, 'The tags should be cached for three hours.' ); + + $this->api_response = array(); + + $this->assertCount( 2, (array) install_popular_tags( $args ), 'The cached tags should be returned.' ); + $this->assertCount( 1, $this->api_requests, 'The API should be asked once.' ); + + install_popular_tags( array( 'number' => 10 ) ); + + $this->assertCount( 2, $this->api_requests, 'Other arguments should not use the cache.' ); + } + + /** + * Tests that an error is returned and not cached. + * + * @ticket 66558 + * + * @covers ::install_popular_tags + */ + public function test_install_popular_tags_should_return_the_error_of_the_api_without_caching_it() { + $this->api_response = new WP_Error( 'plugins_api_failed', 'The API failed.' ); + + $tags = install_popular_tags(); + + $this->assertWPError( $tags ); + $this->assertFalse( get_site_transient( 'poptags_' . md5( serialize( array() ) ) ), 'The error should not be cached.' ); + } + + /** + * Tests the dashboard of the plugin installer. + * + * @ticket 66558 + * + * @covers ::install_dashboard + */ + public function test_install_dashboard_should_output_the_popular_tags() { + $GLOBALS['wp_list_table'] = new class() { + /** + * Outputs the table. + */ + public function display() { + echo '
'; + } + }; + + $output = get_echo( 'install_dashboard' ); + + $this->assertStringContainsString( '
', $output, 'The table should be output.' ); + $this->assertStringContainsString( '

Popular tags

', $output ); + $this->assertStringContainsString( 'You may also browse based on the most popular tags in the Plugin Directory:', $output ); + $this->assertStringContainsString( '
', $output ); + } + + /** + * Tests that the error of the API is output instead of the tags. + * + * @ticket 66558 + * + * @covers ::install_dashboard + */ + public function test_install_dashboard_should_output_the_error_of_the_api() { + $GLOBALS['wp_list_table'] = new class() { + /** + * Outputs the table. + */ + public function display() {} + }; + + $this->api_response = new WP_Error( 'plugins_api_failed', 'The API failed.' ); + + $output = get_echo( 'install_dashboard' ); + + $this->assertStringContainsString( '
', $output ); + $this->assertStringNotContainsString( 'tag-cloud-link', $output ); + } + + /** + * Tests the form to search plugins, with its defaults. + * + * @ticket 66558 + * + * @covers ::install_search_form + */ + public function test_install_search_form_should_search_a_keyword_by_default() { + $output = get_echo( 'install_search_form' ); + + $this->assertStringContainsString( '
', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output, 'The keyword should be selected.' ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( 'id="search-submit"', $output, 'The button should be output.' ); + } + + /** + * Tests the form to search plugins, with a search. + * + * @ticket 66558 + * + * @covers ::install_search_form + */ + public function test_install_search_form_should_use_the_search_of_the_request() { + $_REQUEST['s'] = 'contact+\\"forms\\"'; + $_REQUEST['type'] = 'author'; + + $output = get_echo( 'install_search_form' ); + + $this->assertStringContainsString( 'value="contact "forms""', $output, 'The term should be decoded, unslashed and escaped.' ); + $this->assertStringContainsString( '', $output, 'The type should be selected.' ); + $this->assertStringNotContainsString( 'value="term" selected', $output ); + } + + /** + * Tests the form to upload a plugin. + * + * @ticket 66558 + * + * @covers ::install_plugins_upload + */ + public function test_install_plugins_upload_should_output_the_upload_form() { + $output = get_echo( 'install_plugins_upload' ); + + $this->assertStringContainsString( 'If you have a plugin in a .zip format, you may install or update it by uploading it here.', $output ); + $this->assertStringContainsString( 'enctype="multipart/form-data"', $output ); + $this->assertStringContainsString( 'action="' . esc_url( self_admin_url( 'update.php?action=upload-plugin' ) ) . '"', $output ); + $this->assertStringContainsString( 'name="_wpnonce"', $output, 'The nonce should be output.' ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( 'name="install-plugin-submit"', $output ); + $this->assertStringContainsString( 'value="Install Now"', $output ); + } + + /** + * Tests the form of the favorites, without a user. + * + * @ticket 66558 + * + * @covers ::install_plugins_favorites_form + */ + public function test_install_plugins_favorites_form_should_output_the_form() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $output = get_echo( 'install_plugins_favorites_form' ); + + $this->assertStringContainsString( 'If you have marked plugins as favorites on WordPress.org, you can browse them here.', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( '', $output, 'The username should be empty.' ); + $this->assertStringContainsString( 'value="Get Favorites"', $output ); + $this->assertStringContainsString( '', $output, 'The nonce of the user should be output.' ); + } + + /** + * Tests that the username of the user is filled in. + * + * @ticket 66558 + * + * @covers ::install_plugins_favorites_form + */ + public function test_install_plugins_favorites_form_should_fill_in_the_username() { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + update_user_option( $admin_id, 'wporg_favorites', 'jane"doe' ); + + $output = get_echo( 'install_plugins_favorites_form' ); + + $this->assertStringContainsString( 'value="jane"doe"', $output ); + } + + /** + * Tests the introduction of each tab above the table. + * + * @ticket 66558 + * + * @covers ::display_plugins_table + * + * @dataProvider data_tab_introductions + * + * @param string $filter The name of the filter of the tab. + * @param string $expected The expected text above the table. + */ + public function test_display_plugins_table_should_introduce_the_tab( $filter, $expected ) { + $GLOBALS['wp_list_table'] = new class() { + /** + * Outputs the table. + */ + public function display() { + echo '
'; + } + }; + + $output = $this->display_in_filter( $filter ); + + $this->assertStringStartsWith( $expected, $output ); + $this->assertStringContainsString( '', $output, 'The form should be output.' ); + $this->assertStringContainsString( '
', $output, 'The table should be output.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_tab_introductions() { + return array( + 'beta' => array( 'install_plugins_beta', '

You are using a development version of WordPress. These feature plugins are also under development. Learn more.

' ), + 'featured' => array( 'install_plugins_featured', '
' ), + 'recommended' => array( 'install_plugins_recommended', '

These suggestions are based on the plugins you and other users have installed.

' ), + 'popular' => array( 'install_plugins_popular', "\t'; + } + }; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $this->assertSame( '', $this->display_in_filter( 'install_plugins_favorites' ) ); + } + + /** + * Tests that the table of the favorites is output for a user of the request or of the option. + * + * @ticket 66558 + * + * @covers ::display_plugins_table + */ + public function test_display_plugins_table_should_output_the_favorites_with_a_user() { + $GLOBALS['wp_list_table'] = new class() { + /** + * Outputs the table. + */ + public function display() { + echo '
'; + } + }; + + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $_GET['user'] = 'jane'; + + $this->assertStringContainsString( '
', $this->display_in_filter( 'install_plugins_favorites' ), 'The user of the request should be enough.' ); + + unset( $_GET['user'] ); + update_user_option( $admin_id, 'wporg_favorites', 'jane' ); + + $this->assertStringContainsString( '
', $this->display_in_filter( 'install_plugins_favorites' ), 'The user of the option should be enough.' ); + } + + /** + * Returns the output of display_plugins_table() as it runs for a filter. + * + * @param string $filter The name of the filter. + * @return string The output. + */ + private function display_in_filter( $filter ) { + global $wp_current_filter; + + $wp_current_filter[] = $filter; + + try { + return get_echo( 'display_plugins_table' ); + } finally { + array_pop( $wp_current_filter ); + } + } +} From 9e07e27245585f2dee07ff2b2add5ff582629fc0 Mon Sep 17 00:00:00 2001 From: Md Abul Bashar Date: Fri, 6 Nov 2026 21:23:39 +0600 Subject: [PATCH 2/3] Tests: Check the lifetime of the popular tags only without an external object cache. See #66558. --- .../plugin-install/InstallScreenFunctions_Test.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php index e5e3e3f2620e2..4cf2a6dc81e77 100644 --- a/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php +++ b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php @@ -140,9 +140,12 @@ public function test_install_popular_tags_should_cache_the_tags_by_arguments() { $this->assertEquals( (object) $this->api_response, get_site_transient( 'poptags_' . md5( serialize( $args ) ) ), 'The tags should be cached.' ); - $expires = (int) get_site_option( '_site_transient_timeout_poptags_' . md5( serialize( $args ) ) ); + // With an external object cache, the lifetime is kept by the cache and not in the options. + if ( ! wp_using_ext_object_cache() ) { + $expires = (int) get_site_option( '_site_transient_timeout_poptags_' . md5( serialize( $args ) ) ); - $this->assertEqualsWithDelta( time() + ( 3 * HOUR_IN_SECONDS ), $expires, 10, 'The tags should be cached for three hours.' ); + $this->assertEqualsWithDelta( time() + ( 3 * HOUR_IN_SECONDS ), $expires, 10, 'The tags should be cached for three hours.' ); + } $this->api_response = array(); From 80136fab5b1d6afee45b070e521edf63d5ea8952 Mon Sep 17 00:00:00 2001 From: Md Abul Bashar Date: Sat, 7 Nov 2026 00:02:46 +0600 Subject: [PATCH 3/3] Tests: Check the nonce of the upload form for its action. See #66558. --- .../includes/plugin-install/InstallScreenFunctions_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php index 4cf2a6dc81e77..fae1633c9065f 100644 --- a/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php +++ b/tests/phpunit/tests/admin/includes/plugin-install/InstallScreenFunctions_Test.php @@ -277,7 +277,7 @@ public function test_install_plugins_upload_should_output_the_upload_form() { $this->assertStringContainsString( 'If you have a plugin in a .zip format, you may install or update it by uploading it here.', $output ); $this->assertStringContainsString( 'enctype="multipart/form-data"', $output ); $this->assertStringContainsString( 'action="' . esc_url( self_admin_url( 'update.php?action=upload-plugin' ) ) . '"', $output ); - $this->assertStringContainsString( 'name="_wpnonce"', $output, 'The nonce should be output.' ); + $this->assertStringContainsString( 'name="_wpnonce" value="' . wp_create_nonce( 'plugin-upload' ) . '"', $output, 'The nonce for the action of the upload should be output.' ); $this->assertStringContainsString( '', $output ); $this->assertStringContainsString( 'name="install-plugin-submit"', $output ); $this->assertStringContainsString( 'value="Install Now"', $output );