diff --git a/plugins/embed-optimizer/readme.txt b/plugins/embed-optimizer/readme.txt index b02756cfb..a36210a5a 100644 --- a/plugins/embed-optimizer/readme.txt +++ b/plugins/embed-optimizer/readme.txt @@ -15,9 +15,9 @@ This plugin's purpose is to optimize the performance of [embeds in WordPress](ht The current optimizations include: -1. Lazy loading embeds just before they come into view -2. Adding preconnect links for embeds in the initial viewport -3. Reserving space for embeds that resize to reduce layout shifting +1. Lazy loading embeds just before they come into view. +2. Adding preconnect links for embeds in the initial viewport. +3. Reserving space for embeds that resize to reduce layout shifting. **Lazy loading embeds** improves performance because embeds are generally very resource-intensive, so lazy loading them ensures that they don't compete with resources when the page is loading. Lazy loading of `IFRAME`\-based embeds is handled simply by adding the `loading=lazy` attribute. Lazy loading embeds that include `SCRIPT` tags is handled by using an Intersection Observer to watch for when the embed’s `FIGURE` container is going to enter the viewport and then it dynamically inserts the `SCRIPT` tag. diff --git a/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php index 1e940fab0..4399d7a40 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php @@ -17,7 +17,6 @@ * Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class * * @since 0.2.0 - * * @access private */ final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor { diff --git a/plugins/image-prioritizer/helper.php b/plugins/image-prioritizer/helper.php index 1651dcf55..75c3e18d4 100644 --- a/plugins/image-prioritizer/helper.php +++ b/plugins/image-prioritizer/helper.php @@ -14,6 +14,7 @@ * Initializes Image Prioritizer when Optimization Detective has loaded. * * @since 0.2.0 + * @access private * * @param string $optimization_detective_version Current version of the optimization detective plugin. */ @@ -52,6 +53,7 @@ static function (): void { * See {@see 'wp_head'}. * * @since 0.1.0 + * @access private */ function image_prioritizer_render_generator_meta_tag(): void { // Use the plugin slug as it is immutable. @@ -62,6 +64,7 @@ function image_prioritizer_render_generator_meta_tag(): void { * Registers tag visitors. * * @since 0.1.0 + * @access private * * @param OD_Tag_Visitor_Registry $registry Tag visitor registry. */ @@ -81,6 +84,7 @@ function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $regis * Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer. * * @since n.e.x.t + * @access private * * @param string[]|mixed $extension_module_urls Extension module URLs. * @return string[] Extension module URLs. @@ -97,6 +101,7 @@ function image_prioritizer_filter_extension_module_urls( $extension_module_urls * Filters additional properties for the element item schema for Optimization Detective. * * @since n.e.x.t + * @access private * * @param array $additional_properties Additional properties. * @return array Additional properties. @@ -137,14 +142,193 @@ function image_prioritizer_add_element_item_schema_properties( array $additional return $additional_properties; } +/** + * Validates URL for a background image. + * + * @since n.e.x.t + * @access private + * + * @param string $url Background image URL. + * @return true|WP_Error Validity. + */ +function image_prioritizer_validate_background_image_url( string $url ) { + $parsed_url = wp_parse_url( $url ); + if ( false === $parsed_url || ! isset( $parsed_url['host'] ) ) { + return new WP_Error( + 'background_image_url_lacks_host', + __( 'Supplied background image URL does not have a host.', 'image-prioritizer' ) + ); + } + + $allowed_hosts = array_map( + static function ( $host ) { + return wp_parse_url( $host, PHP_URL_HOST ); + }, + get_allowed_http_origins() + ); + + // Obtain the host of an image attachment's URL in case a CDN is pointing all images to an origin other than the home or site URLs. + $image_attachment_query = new WP_Query( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'post_status' => 'inherit', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_term_cache' => false, // Note that update_post_meta_cache is not included as well because wp_get_attachment_image_src() needs postmeta. + ) + ); + if ( isset( $image_attachment_query->posts[0] ) && is_int( $image_attachment_query->posts[0] ) ) { + $src = wp_get_attachment_image_src( $image_attachment_query->posts[0] ); + if ( is_array( $src ) ) { + $attachment_image_src_host = wp_parse_url( $src[0], PHP_URL_HOST ); + if ( is_string( $attachment_image_src_host ) ) { + $allowed_hosts[] = $attachment_image_src_host; + } + } + } + + // Validate that the background image URL is for an allowed host. + if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) { + return new WP_Error( + 'disallowed_background_image_url_host', + sprintf( + /* translators: %s is the list of allowed hosts */ + __( 'Background image URL host is not among allowed: %s.', 'image-prioritizer' ), + join( ', ', array_unique( $allowed_hosts ) ) + ) + ); + } + + // Validate that the URL points to a valid resource. + $r = wp_safe_remote_head( + $url, + array( + 'redirection' => 3, // Allow up to 3 redirects. + ) + ); + if ( $r instanceof WP_Error ) { + return $r; + } + $response_code = wp_remote_retrieve_response_code( $r ); + if ( $response_code < 200 || $response_code >= 400 ) { + return new WP_Error( + 'background_image_response_not_ok', + sprintf( + /* translators: %s is the HTTP status code */ + __( 'HEAD request for background image URL did not return with a success status code: %s.', 'image-prioritizer' ), + $response_code + ) + ); + } + + // Validate that the Content-Type is an image. + $content_type = (array) wp_remote_retrieve_header( $r, 'content-type' ); + if ( ! is_string( $content_type[0] ) || ! str_starts_with( $content_type[0], 'image/' ) ) { + return new WP_Error( + 'background_image_response_not_image', + sprintf( + /* translators: %s is the content type of the response */ + __( 'HEAD request for background image URL did not return an image Content-Type: %s.', 'image-prioritizer' ), + $content_type[0] + ) + ); + } + + /* + * Validate that the Content-Length is not too massive, as it would be better to err on the side of + * not preloading something so weighty in case the image won't actually end up as LCP. + * The value of 2MB is chosen because according to Web Almanac 2022, the largest image by byte size + * on a page is 1MB at the 90th percentile: . + * The 2MB value is double this 1MB size. + */ + $content_length = (array) wp_remote_retrieve_header( $r, 'content-length' ); + if ( ! is_numeric( $content_length[0] ) ) { + return new WP_Error( + 'background_image_content_length_unknown', + __( 'HEAD request for background image URL did not include a Content-Length response header.', 'image-prioritizer' ) + ); + } elseif ( (int) $content_length[0] > 2 * MB_IN_BYTES ) { + return new WP_Error( + 'background_image_content_length_too_large', + sprintf( + /* translators: %s is the content length of the response */ + __( 'HEAD request for background image URL returned Content-Length greater than 2MB: %s.', 'image-prioritizer' ), + $content_length[0] + ) + ); + } + + return true; +} + +/** + * Sanitizes the lcpElementExternalBackgroundImage property from the request URL Metric storage request. + * + * This removes the lcpElementExternalBackgroundImage from the URL Metric prior to it being stored if the background + * image URL is not valid. Removal of the property is preferable to invalidating the entire URL Metric because then + * potentially no URL Metrics would ever be collected if, for example, the background image URL is pointing to a + * disallowed origin. Then none of the other optimizations would be able to be applied. + * + * @since n.e.x.t + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. + * Usually a WP_REST_Response or WP_Error. + * @param array $handler Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client. + * @noinspection PhpDocMissingThrowsInspection + */ +function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) { + if ( + $request->get_method() !== 'POST' + || + // The strtolower() and outer trim are due to \WP_REST_Server::match_request_to_handler() using case-insensitive pattern match and using '$' instead of '\z'. + OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) ) + ) { + return $response; + } + + $lcp_external_background_image = $request['lcpElementExternalBackgroundImage']; + if ( is_array( $lcp_external_background_image ) && isset( $lcp_external_background_image['url'] ) && is_string( $lcp_external_background_image['url'] ) ) { + $image_validity = image_prioritizer_validate_background_image_url( $lcp_external_background_image['url'] ); + if ( is_wp_error( $image_validity ) ) { + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ + wp_trigger_error( + __FUNCTION__, + sprintf( + /* translators: 1: error message. 2: image url */ + __( 'Error: %1$s. Background image URL: %2$s.', 'image-prioritizer' ), + rtrim( $image_validity->get_error_message(), '.' ), + $lcp_external_background_image['url'] + ) + ); + unset( $request['lcpElementExternalBackgroundImage'] ); + } + } + + return $response; +} + /** * Gets the path to a script or stylesheet. * * @since n.e.x.t + * @access private * * @param string $src_path Source path, relative to plugin root. * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. * @return string URL to script or stylesheet. + * @noinspection PhpDocMissingThrowsInspection */ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = null ): string { if ( null === $min_path ) { @@ -155,6 +339,11 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = $force_src = false; if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) { $force_src = true; + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( __FUNCTION__, sprintf( @@ -181,6 +370,7 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = * Handles 'autoplay' and 'preload' attributes accordingly. * * @since 0.2.0 + * @access private * * @return string Lazy load script. */ @@ -195,6 +385,7 @@ function image_prioritizer_get_video_lazy_load_script(): string { * Load the background image when it approaches the viewport using an IntersectionObserver. * * @since n.e.x.t + * @access private * * @return string Lazy load script. */ @@ -207,6 +398,7 @@ function image_prioritizer_get_lazy_load_bg_image_script(): string { * Gets the stylesheet to lazy-load background images. * * @since n.e.x.t + * @access private * * @return string Lazy load stylesheet. */ diff --git a/plugins/image-prioritizer/hooks.php b/plugins/image-prioritizer/hooks.php index 7587e9e67..636590875 100644 --- a/plugins/image-prioritizer/hooks.php +++ b/plugins/image-prioritizer/hooks.php @@ -13,3 +13,4 @@ add_action( 'od_init', 'image_prioritizer_init' ); add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ); add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ); +add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 ); diff --git a/plugins/image-prioritizer/readme.txt b/plugins/image-prioritizer/readme.txt index 37ea1fb91..867272d30 100644 --- a/plugins/image-prioritizer/readme.txt +++ b/plugins/image-prioritizer/readme.txt @@ -7,7 +7,7 @@ License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, optimization, image, lcp, lazy-load -Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy-loading. +Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading. == Description == @@ -15,13 +15,20 @@ This plugin optimizes the loading of images (and videos) with prioritization, la The current optimizations include: -1. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints. -2. Add breakpoint-specific `fetchpriority=high` preload links for the LCP elements which are `IMG` elements or elements with a CSS `background-image` inline style. -3. Apply lazy-loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. (Additionally, [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is then also correctly applied.) -4. Implement lazy-loading of CSS background images added via inline `style` attributes. -5. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides. +1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements: + 1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`. + 2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.) + 3. An element with a CSS `background-image` inline `style` attribute. + 4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin). + 5. A `VIDEO` element's `poster` image. +2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints. +3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides. +4. Lazy loading: + 1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. + 2. Implement lazy loading of CSS background images added via inline `style` attributes. + 3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport. +5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements. 6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop). -7. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport. **This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.** Please refer to that plugin for additional background on how this plugin works as well as additional developer options. diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index 180a2d807..dfd05b854 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -467,6 +467,469 @@ public function test_image_prioritizer_add_element_item_schema_properties_inputs } } + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_image_prioritizer_validate_background_image_url(): array { + return array( + 'bad_url_parse_error' => array( + 'set_up' => static function (): string { + return 'https:///www.example.com'; + }, + 'expect_error' => 'background_image_url_lacks_host', + ), + 'bad_url_no_host' => array( + 'set_up' => static function (): string { + return '/foo/bar?baz=1'; + }, + 'expect_error' => 'background_image_url_lacks_host', + ), + + 'bad_url_disallowed_origin' => array( + 'set_up' => static function (): string { + return 'https://bad.example.com/foo.jpg'; + }, + 'expect_error' => 'disallowed_background_image_url_host', + ), + + 'good_other_origin_via_allowed_http_origins_filter' => array( + 'set_up' => static function (): string { + $image_url = 'https://other-origin.example.com/foo.jpg'; + + add_filter( + 'allowed_http_origins', + static function ( array $allowed_origins ): array { + $allowed_origins[] = 'https://other-origin.example.com'; + return $allowed_origins; + } + ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => null, + ), + + 'good_url_allowed_cdn_origin' => array( + 'set_up' => function (): string { + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' ); + $this->assertIsInt( $attachment_id ); + + add_filter( + 'wp_get_attachment_image_src', + static function ( $src ): array { + $src[0] = preg_replace( '#^https?://#i', 'https://my-image-cdn.example.com/', $src[0] ); + return $src; + } + ); + + $src = wp_get_attachment_image_src( $attachment_id, 'large' ); + $this->assertIsArray( $src ); + $this->assertStringStartsWith( 'https://my-image-cdn.example.com/', $src[0] ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $src ) { + if ( 'HEAD' !== $parsed_args['method'] || $src[0] !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $src[0]; + }, + 'expect_error' => null, + ), + + 'bad_not_found' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/bad.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'text/html', + 'content-length' => 1000, + ), + 'body' => '', + 'response' => array( + 'code' => 404, + 'message' => 'Not Found', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => 'background_image_response_not_ok', + ), + + 'bad_content_type' => array( + 'set_up' => static function (): string { + $video_url = home_url( '/bad.mp4' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $video_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $video_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'video/mp4', + 'content-length' => '288449000', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $video_url; + }, + 'expect_error' => 'background_image_response_not_image', + ), + + 'bad_content_length' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/massive-image.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => (string) ( 2 * MB_IN_BYTES + 1 ), + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => 'background_image_content_length_too_large', + ), + + 'bad_redirect' => array( + 'set_up' => static function (): string { + $redirect_url = home_url( '/redirect.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $redirect_url ) { + if ( $redirect_url === $url ) { + return new WP_Error( 'http_request_failed', 'Too many redirects.' ); + } + return $pre; + }, + 10, + 3 + ); + + return $redirect_url; + }, + 'expect_error' => 'http_request_failed', + ), + + 'good_same_origin' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/good.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => null, + ), + ); + } + + /** + * Tests image_prioritizer_validate_background_image_url(). + * + * @covers ::image_prioritizer_validate_background_image_url + * + * @dataProvider data_provider_to_test_image_prioritizer_validate_background_image_url + */ + public function test_image_prioritizer_validate_background_image_url( Closure $set_up, ?string $expect_error ): void { + $url = $set_up(); + $validity = image_prioritizer_validate_background_image_url( $url ); + if ( null === $expect_error ) { + $this->assertTrue( $validity ); + } else { + $this->assertInstanceOf( WP_Error::class, $validity ); + $this->assertSame( $expect_error, $validity->get_error_code() ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_image_prioritizer_filter_rest_request_before_callbacks(): array { + $get_sample_url_metric_data = function (): array { + return $this->get_sample_url_metric( array() )->jsonSerialize(); + }; + + $create_request = static function ( array $url_metric_data ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $url_metric_data ) ); + return $request; + }; + + $bad_origin_data = array( + 'url' => 'https://bad-origin.example.com/image.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ); + + return array( + 'invalid_external_bg_image' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + + $url_metric_data['lcpElementExternalBackgroundImage'] = $bad_origin_data; + $url_metric_data['viewport']['width'] = 10101; + $url_metric_data['viewport']['height'] = 20202; + return $create_request( $url_metric_data ); + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( + array( + 'width' => 10101, + 'height' => 20202, + ), + $request['viewport'] + ); + }, + ), + + 'valid_external_bg_image' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + $image_url = home_url( '/good.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + $url_metric_data['lcpElementExternalBackgroundImage'] = array( + 'url' => $image_url, + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ); + + $url_metric_data['viewport']['width'] = 30303; + $url_metric_data['viewport']['height'] = 40404; + return $create_request( $url_metric_data ); + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertIsArray( $request['lcpElementExternalBackgroundImage'] ); + $this->assertSame( + array( + 'url' => home_url( '/good.jpg' ), + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + $request['lcpElementExternalBackgroundImage'] + ); + $this->assertSame( + array( + 'width' => 30303, + 'height' => 40404, + ), + $request['viewport'] + ); + }, + ), + + 'invalid_external_bg_image_uppercase_route' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_route( str_replace( 'store', 'STORE', $request->get_route() ) ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + }, + ), + + 'invalid_external_bg_image_trailing_newline_route' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_route( $request->get_route() . "\n" ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + }, + ), + + 'not_store_post_request' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_method( 'GET' ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ) use ( $bad_origin_data ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( $bad_origin_data, $request['lcpElementExternalBackgroundImage'] ); + }, + ), + + 'not_store_request' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + $url_metric_data['lcpElementExternalBackgroundImage'] = 'https://totally-different.example.com/'; + $request = $create_request( $url_metric_data ); + $request->set_route( '/foo/v2/bar' ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( 'https://totally-different.example.com/', $request['lcpElementExternalBackgroundImage'] ); + }, + ), + ); + } + + /** + * Tests image_prioritizer_filter_rest_request_before_callbacks(). + * + * @dataProvider data_provider_to_test_image_prioritizer_filter_rest_request_before_callbacks + * + * @covers ::image_prioritizer_filter_rest_request_before_callbacks + * @covers ::image_prioritizer_validate_background_image_url + */ + public function test_image_prioritizer_filter_rest_request_before_callbacks( Closure $set_up, Closure $assert ): void { + $request = $set_up(); + $response = new WP_REST_Response(); + $handler = array(); + $filtered_response = image_prioritizer_filter_rest_request_before_callbacks( $response, $handler, $request ); + $this->assertSame( $response, $filtered_response ); + $assert( $request ); + } + /** * Test image_prioritizer_get_video_lazy_load_script. * diff --git a/plugins/image-prioritizer/tests/test-hooks.php b/plugins/image-prioritizer/tests/test-hooks.php index 740c71a3d..840212da4 100644 --- a/plugins/image-prioritizer/tests/test-hooks.php +++ b/plugins/image-prioritizer/tests/test-hooks.php @@ -14,5 +14,6 @@ public function test_hooks_added(): void { $this->assertEquals( 10, has_action( 'od_init', 'image_prioritizer_init' ) ); $this->assertEquals( 10, has_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ) ); $this->assertEquals( 10, has_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ) ); + $this->assertEquals( 10, has_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks' ) ); } } diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 7fc896711..16952a23f 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -645,8 +645,15 @@ public function get_updated_html(): string { * * @param string $function_name Function name. * @param string $message Warning message. + * + * @noinspection PhpDocMissingThrowsInspection */ private function warn( string $function_name, string $message ): void { + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( $function_name, esc_html( $message ) diff --git a/plugins/optimization-detective/class-od-url-metric-group-collection.php b/plugins/optimization-detective/class-od-url-metric-group-collection.php index ed7fe6928..c87ac93e3 100644 --- a/plugins/optimization-detective/class-od-url-metric-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metric-group-collection.php @@ -225,7 +225,7 @@ public function get_last_group(): OD_URL_Metric_Group { } /** - * Clear result cache. + * Clears result cache. * * @since 0.3.0 */ @@ -234,7 +234,7 @@ public function clear_cache(): void { } /** - * Create groups. + * Creates groups. * * @since 0.1.0 * diff --git a/plugins/optimization-detective/class-od-url-metric-group.php b/plugins/optimization-detective/class-od-url-metric-group.php index b32f4b8b6..f8772eca7 100644 --- a/plugins/optimization-detective/class-od-url-metric-group.php +++ b/plugins/optimization-detective/class-od-url-metric-group.php @@ -237,8 +237,7 @@ public function add_url_metric( OD_URL_Metric $url_metric ): void { ); } - $this->result_cache = array(); - $this->collection->clear_cache(); + $this->clear_cache(); $url_metric->set_group( $this ); $this->url_metrics[] = $url_metric; @@ -471,6 +470,16 @@ public function count(): int { return count( $this->url_metrics ); } + /** + * Clears result cache. + * + * @since n.e.x.t + */ + public function clear_cache(): void { + $this->result_cache = array(); + $this->collection->clear_cache(); + } + /** * Specifies data which should be serialized to JSON. * diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index d3b0d984f..f4a4e25cc 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -511,6 +511,15 @@ function ( array $element ): OD_Element { * @return Data Exports to be serialized by json_encode(). */ public function jsonSerialize(): array { - return $this->data; + $data = $this->data; + + $data['elements'] = array_map( + static function ( OD_Element $element ): array { + return $element->jsonSerialize(); + }, + $this->get_elements() + ); + + return $data; } } diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index b9dc348f9..bc9bbfb7f 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -73,6 +73,8 @@ function od_render_generator_meta_tag(): void { * @param string $src_path Source path, relative to plugin root. * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. * @return string URL to script or stylesheet. + * + * @noinspection PhpDocMissingThrowsInspection */ function od_get_asset_path( string $src_path, ?string $min_path = null ): string { if ( null === $min_path ) { @@ -83,6 +85,11 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string $force_src = false; if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) { $force_src = true; + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( __FUNCTION__, sprintf( diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index dbe6afd22..1e783ef82 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -17,7 +17,7 @@ This plugin is a dependency which does not provide end-user functionality on its = Background = -WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy-loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width. +WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width. In order to increase the accuracy of identifying the LCP element, including across various client viewport widths, this plugin gathers metrics from real users (RUM) to detect the actual LCP element and then use this information to optimize the page for future visitors so that the loading of the LCP element is properly prioritized. This is the purpose of Optimization Detective. The approach is heavily inspired by Philip Walton’s [Dynamic LCP Priority: Learning from Past Visits](https://philipwalton.com/articles/dynamic-lcp-priority/). See also the initial exploration document that laid out this project: [Image Loading Optimization via Client-side Detection](https://docs.google.com/document/u/1/d/16qAJ7I_ljhEdx2Cn2VlK7IkiixobY9zNn8FXxN9T9Ls/view). @@ -40,6 +40,33 @@ There are currently **no settings** and no user interface for this plugin since When the `WP_DEBUG` constant is enabled, additional logging for Optimization Detective is added to the browser console. += Use Cases and Examples = + +As mentioned above, this plugin is a dependency that doesn't provide features on its own. Dependent plugins leverage the collected URL Metrics to apply optimizations. What follows us a running list of the optimizations which are enabled by Optimization Detective, along with a links to the related code used for the implementation: + +**[Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer)):** + +1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements: + 1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L167-L177), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349)) + 2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.) ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L192-L275), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349)) + 3. An element with a CSS `background-image` inline `style` attribute. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L62-L92), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L182-L203)) + 4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin). ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L82-L83), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L135-L203), [3](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/helper.php#L83-L264), [4](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/detect.js)) + 5. A `VIDEO` element's `poster` image. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L127-L161)) +2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L65-L91), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146)) +3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L105-L123), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146)) +4. Lazy loading: + 1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L124-L133)) + 2. Implement lazy loading of CSS background images added via inline `style` attributes. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L205-L238), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/helper.php#L369-L382), [3](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/lazy-load-bg-image.js)) + 3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L163-L246), [2](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/helper.php#L352-L367), [3](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/lazy-load-video.js)) +5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements. ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L148-L163)) +6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop). ([1](https://github.com/WordPress/performance/blob/e1d0ac9dd935634b782d711c7e1ae85d296f44cf/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L84-L125)) + +**[Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer)):** + +1. Lazy loading embeds just before they come into view. ([1](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L191-L194), [2](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/hooks.php#L168-L336)) +2. Adding preconnect links for embeds in the initial viewport. ([1](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L114-L190)) +3. Reserving space for embeds that resize to reduce layout shifting. ([1](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/hooks.php#L81-L144), [2](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/detect.js), [3](https://github.com/WordPress/performance/blob/ce76a6a77c15248126b5dab895bc11d0adda0baa/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L218-L285)) + = Hooks = **Action:** `od_init` (argument: plugin version) diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index 814abaac9..8bf337691 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -115,6 +115,7 @@ public static function get_post( string $slug ): ?WP_Post { * * @param WP_Post $post URL Metrics post. * @return OD_URL_Metric[] URL Metrics. + * @noinspection PhpDocMissingThrowsInspection */ public static function get_url_metrics_from_post( WP_Post $post ): array { $this_function = __METHOD__; @@ -123,6 +124,11 @@ public static function get_url_metrics_from_post( WP_Post $post ): array { if ( ! in_array( $error_level, array( E_USER_NOTICE, E_USER_WARNING, E_USER_ERROR, E_USER_DEPRECATED ), true ) ) { $error_level = E_USER_NOTICE; } + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( $this_function, esc_html( $message ), $error_level ); }; diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 38f3c9dda..09ce02501 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -202,7 +202,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { return new WP_Error( 'rest_invalid_param', sprintf( - /* translators: %s is exception name */ + /* translators: %s is exception message */ __( 'Failed to validate URL Metric: %s', 'optimization-detective' ), $e->getMessage() ), @@ -215,9 +215,19 @@ function od_handle_rest_request( WP_REST_Request $request ) { $request->get_param( 'slug' ), $url_metric ); - if ( $result instanceof WP_Error ) { - return $result; + $error_data = array( + 'status' => 500, + ); + if ( WP_DEBUG ) { + $error_data['error_code'] = $result->get_error_code(); + $error_data['error_message'] = $result->get_error_message(); + } + return new WP_Error( + 'unable_to_store_url_metric', + __( 'Unable to store URL Metric.', 'optimization-detective' ), + $error_data + ); } $post_id = $result; diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index ad514aa48..3671b5784 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -28,6 +28,18 @@ public function test_od_register_endpoint_hooked(): void { * @return array */ public function data_provider_to_test_rest_request_good_params(): array { + $add_root_extra_property = static function ( string $property_name ): void { + add_filter( + 'od_url_metric_schema_root_additional_properties', + static function ( array $properties ) use ( $property_name ): array { + $properties[ $property_name ] = array( + 'type' => 'string', + ); + return $properties; + } + ); + }; + return array( 'not_extended' => array( 'set_up' => function (): array { @@ -35,17 +47,8 @@ public function data_provider_to_test_rest_request_good_params(): array { }, ), 'extended' => array( - 'set_up' => function (): array { - add_filter( - 'od_url_metric_schema_root_additional_properties', - static function ( array $properties ): array { - $properties['extra'] = array( - 'type' => 'string', - ); - return $properties; - } - ); - + 'set_up' => function () use ( $add_root_extra_property ): array { + $add_root_extra_property( 'extra' ); $params = $this->get_valid_params(); $params['extra'] = 'foo'; return $params; @@ -96,12 +99,15 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $request = $this->create_request( $valid_params ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 1, did_action( 'od_url_metric_stored' ) ); + + $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); + $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); + $this->assertTrue( $data['success'] ); - $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $post = OD_URL_Metrics_Post_Type::get_post( $valid_params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); @@ -112,20 +118,20 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $expected_data = $valid_params; unset( $expected_data['hmac'], $expected_data['slug'], $expected_data['current_etag'], $expected_data['cache_purge_post_id'] ); + unset( $expected_data['unset_prop'] ); $this->assertSame( $expected_data, wp_array_slice_assoc( $url_metrics[0]->jsonSerialize(), array_keys( $expected_data ) ) ); - $this->assertSame( 1, did_action( 'od_url_metric_stored' ) ); $this->assertInstanceOf( OD_URL_Metric_Store_Request_Context::class, $stored_context ); // Now check that od_trigger_page_cache_invalidation() cleaned caches as expected. $this->assertSame( $url_metrics[0]->jsonSerialize(), $stored_context->url_metric->jsonSerialize() ); - $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); - if ( isset( $valid_params['cache_purge_post_id'] ) ) { - $scheduled = wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $valid_params['cache_purge_post_id'] ) ); + $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); + $this->assertSame( $valid_params['cache_purge_post_id'], $cache_purge_post_id ); + $scheduled = wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $cache_purge_post_id ) ); $this->assertIsInt( $scheduled ); $this->assertGreaterThan( time(), $scheduled ); } diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php index 179d282c4..ef05fc2ab 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php @@ -199,6 +199,40 @@ public function data_provider_sample_size_and_breakpoints(): array { ); } + /** + * Test clear_cache(). + * + * @covers ::clear_cache + * @covers OD_URL_Metric_Group::clear_cache + */ + public function test_clear_cache(): void { + $collection = new OD_URL_Metric_Group_Collection( array(), md5( '' ), array(), 1, DAY_IN_SECONDS ); + $populated_value = array( 'foo' => true ); + $group = $collection->get_first_group(); + + // Get private members. + $collection_result_cache_reflection_property = new ReflectionProperty( OD_URL_Metric_Group_Collection::class, 'result_cache' ); + $collection_result_cache_reflection_property->setAccessible( true ); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + $group_result_cache_reflection_property = new ReflectionProperty( OD_URL_Metric_Group::class, 'result_cache' ); + $group_result_cache_reflection_property->setAccessible( true ); + $this->assertSame( array(), $group_result_cache_reflection_property->getValue( $group ) ); + + // Test clear_cache() on collection. + $collection_result_cache_reflection_property->setValue( $collection, $populated_value ); + $collection->clear_cache(); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + + // Test that adding a URL metric to a collection clears the caches. + $collection_result_cache_reflection_property->setValue( $collection, $populated_value ); + $group_result_cache_reflection_property->setValue( $group, $populated_value ); + $collection->add_url_metric( $this->get_sample_url_metric( array() ) ); + $url_metric = $group->getIterator()->current(); + $this->assertInstanceOf( OD_URL_Metric::class, $url_metric ); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + $this->assertSame( array(), $group_result_cache_reflection_property->getValue( $group ) ); + } + /** * Test add_url_metric(). *