diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 18c9b3e42e..83eca93e81 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -86,6 +86,25 @@ private function validate_data( array $data ): void { if ( is_wp_error( $valid ) ) { throw new OD_Data_Validation_Exception( esc_html( $valid->get_error_message() ) ); } + $aspect_ratio = $data['viewport']['width'] / $data['viewport']['height']; + $min_aspect_ratio = od_get_minimum_viewport_aspect_ratio(); + $max_aspect_ratio = od_get_maximum_viewport_aspect_ratio(); + if ( + $aspect_ratio < $min_aspect_ratio || + $aspect_ratio > $max_aspect_ratio + ) { + throw new OD_Data_Validation_Exception( + esc_html( + sprintf( + /* translators: 1: current aspect ratio, 2: minimum aspect ratio, 3: maximum aspect ratio */ + __( 'Viewport aspect ratio (%1$s) is not in the accepted range of %2$s to %3$s.', 'optimization-detective' ), + $aspect_ratio, + $min_aspect_ratio, + $max_aspect_ratio + ) + ) + ); + } } /** diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 59b4657857..d7e0e2c8d9 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -138,6 +138,8 @@ function getCurrentTime() { * @param {Object} args Args. * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {number} args.minViewportAspectRatio Minimum aspect ratio allowed for the viewport. + * @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport. * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. * @param {string} args.restApiNonce Nonce for writing to the REST API. @@ -152,6 +154,8 @@ function getCurrentTime() { export default async function detect( { serveTime, detectionTimeWindow, + minViewportAspectRatio, + maxViewportAspectRatio, isDebug, restApiEndpoint, restApiNonce, @@ -190,6 +194,20 @@ export default async function detect( { return; } + // Abort if the viewport aspect ratio is not in a common range. + const aspectRatio = win.innerWidth / win.innerHeight; + if ( + aspectRatio < minViewportAspectRatio || + aspectRatio > maxViewportAspectRatio + ) { + if ( isDebug ) { + warn( + `Viewport aspect ratio (${ aspectRatio }) is not in the accepted range of ${ minViewportAspectRatio } to ${ maxViewportAspectRatio }.` + ); + } + return; + } + // Ensure the DOM is loaded (although it surely already is since we're executing in a module). await new Promise( ( resolve ) => { if ( doc.readyState !== 'loading' ) { diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 543b15c492..d6c5252b2a 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -42,6 +42,8 @@ function od_get_detection_script( string $slug, OD_URL_Metrics_Group_Collection $detect_args = array( 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. 'detectionTimeWindow' => $detection_time_window, + 'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(), + 'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(), 'isDebug' => WP_DEBUG, 'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ), 'restApiNonce' => wp_create_nonce( 'wp_rest' ), diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index 5af82ef2f3..dcc37e4e4b 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -96,6 +96,29 @@ add_filter( 'od_url_metric_freshness_ttl', '__return_zero' ); Filters the time window between serve time and run time in which loading detection is allowed to run. This amount is the allowance between when the page was first generated (and perhaps cached) and when the detect function on the page is allowed to perform its detection logic and submit the request to store the results. This avoids situations in which there are missing URL Metrics in which case a site with page caching which also has a lot of traffic could result in a cache stampede. +**Filter:** `od_minimum_viewport_aspect_ratio` (default: 0.4) + +Filters the minimum allowed viewport aspect ratio for URL metrics. + +The 0.4 value is intended to accommodate the phone with the greatest known aspect +ratio at 21:9 when rotated 90 degrees to 9:21 (0.429). + +**Filter:** `od_maximum_viewport_aspect_ratio` (default: 2.5) + +Filters the maximum allowed viewport aspect ratio for URL metrics. + +The 2.5 value is intended to accommodate the phone with the greatest known aspect +ratio at 21:9 (2.333). + +During development when you have the DevTools console open, for example, the viewport aspect ratio will be wider than normal. In this case, you may want to increase the maximum aspect ratio: + +` +post_content, true ); if ( json_last_error() !== 0 ) { - $trigger_warning( + $trigger_error( sprintf( /* translators: 1: Post type slug, 2: Post ID, 3: JSON error message */ __( 'Contents of %1$s post type (ID: %2$s) not valid JSON: %3$s', 'optimization-detective' ), self::SLUG, $post->ID, json_last_error_msg() - ) + ), + E_USER_WARNING ); $url_metrics_data = array(); } elseif ( ! is_array( $url_metrics_data ) ) { - $trigger_warning( + $trigger_error( sprintf( /* translators: %s is post type slug */ __( 'Contents of %s post type was not a JSON array.', 'optimization-detective' ), self::SLUG - ) + ), + E_USER_WARNING ); $url_metrics_data = array(); } @@ -148,7 +150,7 @@ public static function get_url_metrics_from_post( WP_Post $post ): array { return array_values( array_filter( array_map( - static function ( $url_metric_data ) use ( $trigger_warning ) { + static function ( $url_metric_data ) use ( $trigger_error ) { if ( ! is_array( $url_metric_data ) ) { return null; } @@ -156,13 +158,21 @@ static function ( $url_metric_data ) use ( $trigger_warning ) { try { return new OD_URL_Metric( $url_metric_data ); } catch ( OD_Data_Validation_Exception $e ) { - $trigger_warning( + $suffix = ''; + if ( isset( $url_metric_data['uuid'] ) && is_string( $url_metric_data['uuid'] ) ) { + $suffix .= sprintf( ' (URL Metric UUID: %s)', $url_metric_data['uuid'] ); + } + + $trigger_error( sprintf( /* translators: 1: Post type slug. 2: Exception message. */ __( 'Unexpected shape to JSON array in post_content of %1$s post type: %2$s', 'optimization-detective' ), OD_URL_Metrics_Post_Type::SLUG, - $e->getMessage() - ) + $e->getMessage() . $suffix + ), + // This is not a warning because schema changes will happen, and so it is expected + // that this will result in existing URL metrics being invalidated. + E_USER_NOTICE ); return null; diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index c3395f9658..cbb31f672d 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -179,6 +179,50 @@ function od_verify_url_metrics_storage_nonce( string $nonce, string $slug, strin return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug:$url" ); } +/** + * Gets the minimum allowed viewport aspect ratio for URL metrics. + * + * @since n.e.x.t + * @access private + * + * @return float Minimum viewport aspect ratio for URL metrics. + */ +function od_get_minimum_viewport_aspect_ratio(): float { + /** + * Filters the minimum allowed viewport aspect ratio for URL metrics. + * + * The 0.4 default value is intended to accommodate the phone with the greatest known aspect + * ratio at 21:9 when rotated 90 degrees to 9:21 (0.429). + * + * @since n.e.x.t + * + * @param float $minimum_viewport_aspect_ratio Minimum viewport aspect ratio. + */ + return (float) apply_filters( 'od_minimum_viewport_aspect_ratio', 0.4 ); +} + +/** + * Gets the maximum allowed viewport aspect ratio for URL metrics. + * + * @since n.e.x.t + * @access private + * + * @return float Maximum viewport aspect ratio for URL metrics. + */ +function od_get_maximum_viewport_aspect_ratio(): float { + /** + * Filters the maximum allowed viewport aspect ratio for URL metrics. + * + * The 2.5 default value is intended to accommodate the phone with the greatest known aspect + * ratio at 21:9 (2.333). + * + * @since n.e.x.t + * + * @param float $maximum_viewport_aspect_ratio Maximum viewport aspect ratio. + */ + return (float) apply_filters( 'od_maximum_viewport_aspect_ratio', 2.5 ); +} + /** * Gets the breakpoint max widths to group URL metrics for various viewports. * diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 609e46723f..fb16ed1441 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -143,12 +143,13 @@ function od_handle_rest_request( WP_REST_Request $request ) { ); } catch ( OD_Data_Validation_Exception $e ) { return new WP_Error( - 'url_metric_exception', + 'rest_invalid_param', sprintf( /* translators: %s is exception name */ __( 'Failed to validate URL metric: %s', 'optimization-detective' ), $e->getMessage() - ) + ), + array( 'status' => 400 ) ); } diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 1adfaf5946..7009fccc59 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -332,6 +332,42 @@ static function ( int $life, string $action ) use ( &$nonce_life_actions ): int } } + /** + * Test od_get_minimum_viewport_aspect_ratio(). + * + * @covers ::od_get_minimum_viewport_aspect_ratio + */ + public function test_od_get_minimum_viewport_aspect_ratio(): void { + $this->assertSame( 0.4, od_get_minimum_viewport_aspect_ratio() ); + + add_filter( + 'od_minimum_viewport_aspect_ratio', + static function () { + return '0.6'; + } + ); + + $this->assertSame( 0.6, od_get_minimum_viewport_aspect_ratio() ); + } + + /** + * Test od_get_maximum_viewport_aspect_ratio(). + * + * @covers ::od_get_maximum_viewport_aspect_ratio + */ + public function test_od_get_maximum_viewport_aspect_ratio(): void { + $this->assertSame( 2.5, od_get_maximum_viewport_aspect_ratio() ); + + add_filter( + 'od_maximum_viewport_aspect_ratio', + static function () { + return 3; + } + ); + + $this->assertSame( 3.0, od_get_maximum_viewport_aspect_ratio() ); + } + /** * Test od_get_breakpoint_max_widths(). * diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 26cd8a7992..5661a469bc 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -85,6 +85,12 @@ function ( $params ) { 'depth' => 200, ), ), + 'invalid_viewport_aspect_ratio' => array( + 'viewport' => array( + 'width' => 1024, + 'height' => 12000, + ), + ), 'invalid_elements_type' => array( 'elements' => 'bad', ), @@ -235,7 +241,14 @@ public function test_rest_request_breakpoint_not_needed_for_any_breakpoint(): vo foreach ( $viewport_widths as $viewport_width ) { $this->populate_url_metrics( $sample_size, - $this->get_valid_params( array( 'viewport' => array( 'width' => $viewport_width ) ) ) + $this->get_valid_params( + array( + 'viewport' => array( + 'width' => $viewport_width, + 'height' => ceil( $viewport_width / 2 ), + ), + ) + ) ); } diff --git a/plugins/optimization-detective/tests/test-class-od-url-metric.php b/plugins/optimization-detective/tests/test-class-od-url-metric.php index a1f1166d4e..d301997d58 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metric.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metric.php @@ -29,7 +29,7 @@ public function data_provider(): array { ); return array( - 'valid_minimal' => array( + 'valid_minimal' => array( 'data' => array( 'url' => home_url( '/' ), 'viewport' => $viewport, @@ -37,7 +37,7 @@ public function data_provider(): array { 'elements' => array(), ), ), - 'valid_with_element' => array( + 'valid_with_element' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -48,7 +48,7 @@ public function data_provider(): array { ), ), ), - 'bad_uuid' => array( + 'bad_uuid' => array( 'data' => array( 'uuid' => 'foo', 'url' => home_url( '/' ), @@ -58,7 +58,7 @@ public function data_provider(): array { ), 'error' => 'OD_URL_Metric[uuid] is not a valid UUID.', ), - 'missing_viewport' => array( + 'missing_viewport' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -67,7 +67,7 @@ public function data_provider(): array { ), 'error' => 'viewport is a required property of OD_URL_Metric.', ), - 'missing_viewport_width' => array( + 'missing_viewport_width' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -77,7 +77,7 @@ public function data_provider(): array { ), 'error' => 'width is a required property of OD_URL_Metric[viewport].', ), - 'bad_viewport' => array( + 'bad_viewport' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -90,7 +90,33 @@ public function data_provider(): array { ), 'error' => 'OD_URL_Metric[viewport][height] is not of type integer.', ), - 'missing_timestamp' => array( + 'viewport_aspect_ratio_too_small' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'url' => home_url( '/' ), + 'viewport' => array( + 'width' => 1000, + 'height' => 10000, + ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'Viewport aspect ratio (0.1) is not in the accepted range of 0.4 to 2.5.', + ), + 'viewport_aspect_ratio_too_large' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'url' => home_url( '/' ), + 'viewport' => array( + 'width' => 10000, + 'height' => 1000, + ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'Viewport aspect ratio (10) is not in the accepted range of 0.4 to 2.5.', + ), + 'missing_timestamp' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -99,7 +125,7 @@ public function data_provider(): array { ), 'error' => 'timestamp is a required property of OD_URL_Metric.', ), - 'missing_elements' => array( + 'missing_elements' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -108,7 +134,7 @@ public function data_provider(): array { ), 'error' => 'elements is a required property of OD_URL_Metric.', ), - 'missing_url' => array( + 'missing_url' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'viewport' => $viewport, @@ -117,7 +143,7 @@ public function data_provider(): array { ), 'error' => 'url is a required property of OD_URL_Metric.', ), - 'bad_elements' => array( + 'bad_elements' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), @@ -131,7 +157,7 @@ public function data_provider(): array { ), 'error' => 'isLCP is a required property of OD_URL_Metric[elements][0].', ), - 'bad_intersection_width' => array( + 'bad_intersection_width' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), 'url' => home_url( '/' ), diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php index 47c04ff4cb..6224820754 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php @@ -179,11 +179,11 @@ public function test_is_viewport_width_in_range( int $minimum_viewport_width, in public function data_provider_test_add_url_metric(): array { return array( 'out_of_range' => array( - 'viewport_width' => 1, + 'viewport_width' => 400, 'exception' => InvalidArgumentException::class, ), 'within_range' => array( - 'viewport_width' => 100, + 'viewport_width' => 600, 'exception' => '', ), ); @@ -199,7 +199,7 @@ public function test_add_url_metric( int $viewport_width, string $exception ): v if ( '' !== $exception ) { $this->expectException( $exception ); } - $group = new OD_URL_Metrics_Group( array(), 100, 200, 1, HOUR_IN_SECONDS ); + $group = new OD_URL_Metrics_Group( array(), 480, 799, 1, HOUR_IN_SECONDS ); $this->assertFalse( $group->is_complete() ); $group->add_url_metric( @@ -208,7 +208,7 @@ public function test_add_url_metric( int $viewport_width, string $exception ): v 'url' => home_url( '/' ), 'viewport' => array( 'width' => $viewport_width, - 'height' => 1000, + 'height' => ceil( $viewport_width / 2 ), ), 'timestamp' => microtime( true ), 'elements' => array(), diff --git a/tests/class-optimization-detective-test-helpers.php b/tests/class-optimization-detective-test-helpers.php index eaa9210270..ba273a3e89 100644 --- a/tests/class-optimization-detective-test-helpers.php +++ b/tests/class-optimization-detective-test-helpers.php @@ -92,7 +92,7 @@ public function get_sample_url_metric( array $params ): OD_URL_Metric { 'url' => home_url( '/' ), 'viewport' => array( 'width' => $params['viewport_width'], - 'height' => 800, + 'height' => $params['viewport_height'] ?? ceil( $params['viewport_width'] / 2 ), ), 'timestamp' => microtime( true ), 'elements' => array_map(