diff --git a/includes/Checker/Checks/Performance/Non_Blocking_Scripts_Check.php b/includes/Checker/Checks/Performance/Non_Blocking_Scripts_Check.php new file mode 100644 index 000000000..d7b46a0f2 --- /dev/null +++ b/includes/Checker/Checks/Performance/Non_Blocking_Scripts_Check.php @@ -0,0 +1,243 @@ +backup_globals(); + + return function () use ( $orig_scripts ) { + if ( is_null( $orig_scripts ) ) { + unset( $GLOBALS['wp_scripts'] ); + } else { + $GLOBALS['wp_scripts'] = $orig_scripts; + } + + $this->restore_globals(); + }; + } + + /** + * Returns an array of shared preparations for the check. + * + * @since 1.1.0 + * + * @return array Returns a map of $class_name => $constructor_args pairs. If the class does not + * need any constructor arguments, it would just be an empty array. + */ + public function get_shared_preparations() { + $demo_posts = array_map( + static function ( $post_type ) { + return array( + 'post_title' => "Demo {$post_type} post", + 'post_content' => 'Test content', + 'post_type' => $post_type, + 'post_status' => 'publish', + ); + }, + $this->get_viewable_post_types() + ); + + return array( + Demo_Posts_Creation_Preparation::class => array( $demo_posts ), + ); + } + + /** + * Runs the check on the plugin and amends results. + * + * @since 1.1.0 + * + * @param Check_Result $result The check results to amend and the plugin context. + */ + public function run( Check_Result $result ) { + $this->run_for_urls( + $this->get_urls(), + function ( $url ) use ( $result ) { + $this->check_url( $result, $url ); + } + ); + } + + /** + * Gets the list of URLs to run this check for. + * + * @since 1.1.0 + * + * @return array List of URL strings (either full URLs or paths). + * + * @throws Exception Thrown when a post type URL cannot be retrieved. + */ + protected function get_urls() { + $urls = array( home_url() ); + + foreach ( $this->get_viewable_post_types() as $post_type ) { + $posts = get_posts( + array( + 'posts_per_page' => 1, + 'post_type' => $post_type, + 'post_status' => array( 'publish', 'inherit' ), + ) + ); + + if ( ! isset( $posts[0] ) ) { + throw new Exception( + sprintf( + /* translators: %s: The Post Type name. */ + __( 'Unable to retrieve post URL for post type: %s', 'plugin-check' ), + $post_type + ) + ); + } + + $urls[] = get_permalink( $posts[0] ); + } + + return $urls; + } + + /** + * Amends the given result by running the check for the given URL. + * + * @since 1.1.0 + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param string $url URL to run the check for. + * + * @throws Exception Thrown when the check fails with a critical error (unrelated to any errors detected as part of + * the check). + * + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + protected function check_url( Check_Result $result, $url ) { + // Reset the WP_Scripts instance. + unset( $GLOBALS['wp_scripts'] ); + + // Run the 'wp_enqueue_script' action, wrapped in an output buffer in case of any callbacks printing scripts + // directly. This is discouraged, but some plugins or themes are still doing it. + ob_start(); + wp_enqueue_scripts(); + wp_scripts()->do_head_items(); + wp_scripts()->do_footer_items(); + ob_end_clean(); + + foreach ( wp_scripts()->done as $handle ) { + $script = wp_scripts()->registered[ $handle ]; + + // TODO: Somehow detect inline scripts added by the plugin that don't have a `src`. + + if ( ! $script->src || strpos( $script->src, $result->plugin()->url() ) !== 0 ) { + continue; + } + + if ( ! empty( $script->extra['strategy'] ) ) { + continue; + } + + $script_path = str_replace( $result->plugin()->url(), $result->plugin()->path(), $script->src ); + + if ( ! in_array( $handle, wp_scripts()->in_footer, true ) ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: 1: tested URL. 2: the script handle. 3: 'defer'. 4: 'async' */ + __( 'This script on %1$s (with handle %2$s) is potentially blocking. Consider a %3$s or %4$s script strategy or moving it to the footer.', 'plugin-check' ), + $url, + $handle, + 'defer', + 'async' + ), + 'NonBlockingScripts.BlockingHeadScript', + $script_path + ); + } else { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: 1: tested URL. 2: the script handle. 3: 'defer'. 4: 'async' */ + __( 'This script on %1$s (with handle %2$s) is loaded in the footer. Consider a %3$s or %4$s script loading strategy instead.', 'plugin-check' ), + $url, + $handle, + 'defer', + 'async' + ), + 'NonBlockingScripts.NoStrategy', + $script_path + ); + } + } + } + + /** + * Returns an array of viewable post types. + * + * @since 1.1.0 + * + * @return array Array of viewable post type slugs. + */ + private function get_viewable_post_types() { + if ( ! is_array( $this->viewable_post_types ) ) { + $this->viewable_post_types = array_filter( get_post_types(), 'is_post_type_viewable' ); + } + + return $this->viewable_post_types; + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index 4d68f1793..171fb117d 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -58,6 +58,7 @@ private function register_default_checks() { 'localhost' => new Checks\Plugin_Repo\Localhost_Check(), 'no_unfiltered_uploads' => new Checks\Plugin_Repo\No_Unfiltered_Uploads_Check(), 'trademarks' => new Checks\Plugin_Repo\Trademarks_Check(), + 'non_blocking_scripts' => new Checks\Performance\Non_Blocking_Scripts_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/load.php b/tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/load.php new file mode 100644 index 000000000..e46b0cef1 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/load.php @@ -0,0 +1,50 @@ + false, + ) + ); + + wp_enqueue_script( + 'plugin_check_test_script_footer', + plugin_dir_url( __FILE__ ) . 'footer.js', + array(), + false, + array( + 'in_footer' => true, + ) + ); + + wp_enqueue_script( + 'plugin_check_test_script_defer', + plugin_dir_url( __FILE__ ) . 'defer.js', + array(), + false, + array( + 'strategy' => 'defer' + ) + ); + + wp_enqueue_script( + 'plugin_check_test_script_async', + plugin_dir_url( __FILE__ ) . 'async.js', + array(), + false, + array( + 'strategy' => 'async' + ) + ); + } +); + diff --git a/tests/phpunit/tests/Checker/Checks/Non_Blocking_Scripts_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Non_Blocking_Scripts_Check_Tests.php new file mode 100644 index 000000000..67362028f --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Non_Blocking_Scripts_Check_Tests.php @@ -0,0 +1,89 @@ +get_shared_preparations(); + + $this->assertIsArray( $preparations ); + + foreach ( $preparations as $class => $args ) { + $instance = new $class( ...$args ); + $this->assertInstanceOf( Preparation::class, $instance ); + } + } + + public function test_prepare() { + // Create variables in global state. + $_GET['test_prepare'] = true; + $_POST['test_prepare'] = true; + $_SERVER['test_prepare'] = true; + + $current_screen = $GLOBALS['current_screen']; + $GLOBALS['current_screen'] = 'test_prepare'; + + $check = new Non_Blocking_Scripts_Check(); + $cleanup = $check->prepare(); + + // Modify the variables in the global state. + $_GET['test_prepare'] = false; + $_POST['test_prepare'] = false; + $_SERVER['test_prepare'] = false; + $GLOBALS['current_screen'] = 'altered'; + + $cleanup(); + + $test_get = $_GET['test_prepare']; + $test_post = $_POST['test_prepare']; + $test_server = $_SERVER['test_prepare']; + $test_globals = $GLOBALS['current_screen']; + + // Restore the global state. + unset( $_GET['test_prepare'] ); + unset( $_POST['test_prepare'] ); + unset( $_SERVER['test_prepare'] ); + $GLOBALS['current_screen'] = $current_screen; + + $this->assertTrue( $test_get ); + $this->assertTrue( $test_post ); + $this->assertTrue( $test_server ); + $this->assertSame( 'test_prepare', $test_globals ); + } + + public function test_run_with_warnings() { + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-non-blocking-scripts-check/load.php'; + + $check = new Non_Blocking_Scripts_Check(); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $errors = $results->get_errors(); + $warnings = $results->get_warnings(); + + $this->assertEmpty( $errors ); + $this->assertNotEmpty( $warnings ); + + $header_script = 'tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/header.js'; + $footer_script = 'tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/footer.js'; + $async_script = 'tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/async.js'; + $defer_script = 'tests/phpunit/testdata/plugins/test-plugin-non-blocking-scripts-check/defer.js'; + + $this->assertArrayNotHasKey( $async_script, $warnings, 'An async script should not cause any warnings' ); + $this->assertArrayNotHasKey( $defer_script, $warnings, 'A deferred script should not cause any warnings' ); + $this->assertArrayHasKey( $header_script, $warnings, 'A header script should cause a warning' ); + $this->assertArrayHasKey( $footer_script, $warnings, 'A footer script should cause a warning' ); + + $this->assertSame( 'NonBlockingScripts.BlockingHeadScript', $warnings[ $header_script ][0][0][0]['code'] ); + $this->assertSame( 'NonBlockingScripts.NoStrategy', $warnings[ $footer_script ][0][0][0]['code'] ); + } +}