From 57f99584fc9afc76610446c10dd7f84ea2aab1d1 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 23 Jan 2025 23:58:23 +0530 Subject: [PATCH 01/62] Add settings page for Optimization Detective --- plugins/optimization-detective/hooks.php | 1 + plugins/optimization-detective/load.php | 3 ++ plugins/optimization-detective/settings.php | 47 +++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 plugins/optimization-detective/settings.php diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index ff2908390f..9cbdce2fd0 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -23,4 +23,5 @@ add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' ); add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ); add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 ); +add_action( 'admin_menu', 'od_add_optimization_detective_menu' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 6bdc459ad5..ffc15b627d 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -131,5 +131,8 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec // Load site health checks. require_once __DIR__ . '/site-health.php'; + + // Load the settings page. + require_once __DIR__ . '/settings.php'; } ); diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php new file mode 100644 index 0000000000..08f982e484 --- /dev/null +++ b/plugins/optimization-detective/settings.php @@ -0,0 +1,47 @@ + +
+

+
+

+
+
+ + Date: Mon, 27 Jan 2025 18:44:36 +0530 Subject: [PATCH 02/62] Add initial admin script and UI elements for URL priming --- plugins/optimization-detective/helper.php | 17 +++++ plugins/optimization-detective/hooks.php | 1 + .../prime-url-metrics.js | 74 +++++++++++++++++++ plugins/optimization-detective/settings.php | 7 ++ 4 files changed, 99 insertions(+) create mode 100644 plugins/optimization-detective/prime-url-metrics.js diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 20044dd8fe..90d8f08f43 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -122,3 +122,20 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string return $min_path; } + +/** + * Enqueues admin scripts. + * + * @param string $hook_suffix Current admin page. + */ +function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { + if ( 'tools_page_od-optimization-detective' === $hook_suffix ) { + wp_enqueue_script( + 'od-prime-url-metrics', + plugins_url( od_get_asset_path( 'prime-url-metrics.js' ), __FILE__ ), + array( 'wp-i18n', 'wp-api-fetch' ), + OPTIMIZATION_DETECTIVE_VERSION, + true + ); + } +} diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 9cbdce2fd0..dcf362fe54 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -24,4 +24,5 @@ add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ); add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 ); add_action( 'admin_menu', 'od_add_optimization_detective_menu' ); +add_action( 'admin_enqueue_scripts', 'od_enqueue_prime_url_metrics_scripts' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js new file mode 100644 index 0000000000..cf063ba9d4 --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -0,0 +1,74 @@ +/** + * Helper script for the Prime URL Metrics. + */ + +( function () { + // @ts-ignore + const { i18n, apiFetch } = wp; + const { __ } = i18n; + + const controlButton = document.getElementById( + 'od-prime-url-metrics-control-button' + ); + const progressBar = document.getElementById( + 'od-prime-url-metrics-progress-bar' + ); + + const iframe = document.getElementById( 'od-prime-url-metrics-iframe' ); + + let isInitialized = false; + let isProcessing = false; + let isNextBatchAvailable = true; + + /** + * Handles the prime URL metrics button click. + */ + async function handleControlButtonClick() { + if ( isProcessing ) { + controlButton.textContent = __( + 'Resume', + 'optimization-detective' + ); + isProcessing = false; + } else { + controlButton.textContent = __( 'Pause', 'optimization-detective' ); + isProcessing = true; + } + + if ( ! isInitialized ) { + isInitialized = true; + while ( isProcessing && isNextBatchAvailable ) { + const batch = await getBatch(); + // @ts-ignore + progressBar.max += batch.length; + if ( ! batch.length ) { + isNextBatchAvailable = false; + break; + } + await processBatch( batch ); + } + } + } + + /** + * Fetches the next batch of URLs. + */ + async function getBatch() { + const response = await apiFetch( '' ); + return response; + } + + async function processBatch( batch ) { + for ( const url of batch ) { + // @ts-ignore + iframe.src = url; + await new Promise( ( resolve ) => { + iframe.onload = resolve; + } ); + // @ts-ignore + progressBar.value += 1; + } + } + + controlButton.addEventListener( 'click', handleControlButtonClick ); +} )(); diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 08f982e484..af709fe57b 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -40,6 +40,13 @@ function od_render_optimization_detective_page(): void {

+
+ + +
+ +
+
From da1d27515e53d21c7eda0fa86c6278f7a1be9e6e Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 28 Jan 2025 00:33:20 +0530 Subject: [PATCH 03/62] Implement batch URL priming functionality with REST API endpoint --- .../prime-url-metrics.js | 63 +++++-- plugins/optimization-detective/settings.php | 2 +- .../storage/rest-api.php | 163 ++++++++++++++++++ 3 files changed, 210 insertions(+), 18 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index cf063ba9d4..eb9c25f9db 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -7,21 +7,28 @@ const { i18n, apiFetch } = wp; const { __ } = i18n; - const controlButton = document.getElementById( - 'od-prime-url-metrics-control-button' + /** @type {HTMLButtonElement} */ + const controlButton = document.querySelector( + 'button#od-prime-url-metrics-control-button' ); - const progressBar = document.getElementById( - 'od-prime-url-metrics-progress-bar' + + /** @type {HTMLProgressElement} */ + const progressBar = document.querySelector( + 'progress#od-prime-url-metrics-progress' ); - const iframe = document.getElementById( 'od-prime-url-metrics-iframe' ); + /** @type {HTMLIFrameElement} */ + const iframe = document.querySelector( + 'iframe#od-prime-url-metrics-iframe' + ); let isInitialized = false; let isProcessing = false; let isNextBatchAvailable = true; + let cursor = {}; /** - * Handles the prime URL metrics button click. + * Handles the prime URL metrics control button click. */ async function handleControlButtonClick() { if ( isProcessing ) { @@ -37,35 +44,57 @@ if ( ! isInitialized ) { isInitialized = true; + iframe.style.display = 'block'; while ( isProcessing && isNextBatchAvailable ) { - const batch = await getBatch(); - // @ts-ignore - progressBar.max += batch.length; - if ( ! batch.length ) { + /** + * @type {{ + * urls: Array, + * cursor: { + * provider_index: number, + * subtype_index: number, + * page_number: number, + * offset_within_page: number, + * batch_size: number + * } + * }} + */ + const batch = await getBatch( cursor ); + cursor = batch.cursor; + progressBar.max += batch.urls.length; + if ( ! batch.urls.length ) { isNextBatchAvailable = false; break; } - await processBatch( batch ); + await processBatch( batch.urls ); } } } /** * Fetches the next batch of URLs. + * @param {Object} lastCursor - The cursor to fetch the next batch. + * @return {Promise<{urls: string[], cursor: {provider_index: number, subtype_index: number, page_number: number, offset_within_page: number, batch_size: number}}>} The promise that resolves to the next batch of URLs. */ - async function getBatch() { - const response = await apiFetch( '' ); + async function getBatch( lastCursor ) { + const response = await apiFetch( { + path: '/optimization-detective/v1/prime-urls', + method: 'POST', + data: { cursor: lastCursor }, + } ); return response; } - async function processBatch( batch ) { - for ( const url of batch ) { - // @ts-ignore + /** + * Processes the batch of URLs. + * @param {Array} urls - The URLs to process + * @return {Promise} The promise that resolves to void. + */ + async function processBatch( urls ) { + for ( const url of urls ) { iframe.src = url; await new Promise( ( resolve ) => { iframe.onload = resolve; } ); - // @ts-ignore progressBar.value += 1; } } diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index af709fe57b..46f0eb358d 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -44,7 +44,7 @@ function od_render_optimization_detective_page(): void {
- +
diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index f4b086177e..b7db2a8fe0 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -31,6 +31,13 @@ */ const OD_URL_METRICS_ROUTE = '/url-metrics:store'; +/** + * Route for getting URLs that need to be primed. + * + * @var string + */ +const OD_PRIME_URLS_ROUTE = '/prime-urls'; + /** * Registers endpoint for storage of URL Metric. * @@ -105,6 +112,20 @@ function od_register_endpoint(): void { }, ) ); + + register_rest_route( + OD_REST_API_NAMESPACE, + OD_PRIME_URLS_ROUTE, + array( + 'methods' => 'POST', + 'callback' => static function ( WP_REST_Request $request ) { + return od_handle_generate_batch_urls_request( $request ); + }, + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + ) + ); } add_action( 'rest_api_init', 'od_register_endpoint' ); @@ -270,6 +291,148 @@ function od_handle_rest_request( WP_REST_Request $request ) { ); } +/** + * Handles REST API request to generate batch URLs. + * + * @since n.e.x.t + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response Response. + */ +function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_REST_Response { + $cursor = $request->get_param( 'cursor' ); + + // Initialize cursor with default values. + $cursor = wp_parse_args( + $cursor, + array( + 'provider_index' => 0, + 'subtype_index' => 0, + 'page_number' => 1, + 'offset_within_page' => 0, + 'batch_size' => 1, + ) + ); + + // Get the server & its registry of sitemap providers. + $server = wp_sitemaps_get_server(); + $registry = $server->registry; + + // All registered providers. + $providers = array_values( $registry->get_providers() ); // Ensure zero-based index. + + $all_urls = array(); + $collected_count = 0; + + // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). + $done = false; + + // Start iterating from the current provider_index forward. + $providers_count = count( $providers ); + for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { + $provider = $providers[ $p ]; + + // WordPress providers return an array of strings from get_object_subtypes(). + $subtypes = array_values( $provider->get_object_subtypes() ); // zero-based index. + + // Start from the current subtype_index if resuming. + $subtypes_count = count( $subtypes ); + for ( $s = ( $p === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $s < $subtypes_count && ! $done; ) { + // This is a string, e.g. 'post', 'page', etc. + $subtype = $subtypes[ $s ]; + + // Retrieve the max number of pages for this subtype. + $max_num_pages = $provider->get_max_num_pages( $subtype->name ); + + // Start from the current page_number if resuming. + for ( $page = ( ( $p === $cursor['provider_index'] ) && ( $s === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) { + $url_list = $provider->get_url_list( $page, $subtype->name ); + if ( ! is_array( $url_list ) ) { + continue; + } + + // Filter out empty URLs. + $url_chunk = array_filter( array_column( $url_list, 'loc' ) ); + + // We might have partially consumed this page, so skip $cursor['offset_within_page'] items first. + $current_page_urls = array_slice( $url_chunk, $cursor['offset_within_page'] ); + + // Count how many URLs we consumed in this page. + $consumed_in_this_page = 0; + + // Now collect from current_page_urls until we reach $cursor['batch_size']. + foreach ( $current_page_urls as $url ) { + $all_urls[] = $url; + ++$collected_count; + ++$consumed_in_this_page; + + if ( $collected_count >= $cursor['batch_size'] ) { + // We have our full batch; stop collecting further. + $done = true; + break; + } + } + + if ( ! $done ) { + // We consumed this entire page, so if we continue, next time we start at offset 0 of the next page. + $cursor['page_number'] = $page + 1; + $cursor['offset_within_page'] = 0; + } else { + // We reached the limit in the middle of this page. + // Figure out how many we used from this page to update the offset properly. + $extra_consumed = $collected_count - $cursor['batch_size']; // If exactly $cursor['batch_size'], this might be 0 or negative. + if ( $extra_consumed < 0 ) { + $extra_consumed = 0; + } + + $cursor['offset_within_page'] = $cursor['offset_within_page'] + ( $consumed_in_this_page - $extra_consumed ); + + // We haven't fully finished this page, so keep the same $cursor['page_number']. + $cursor['page_number'] = $page; + } + } // end for pages + + if ( ! $done ) { + // If we've finished all pages in this subtype, move to next subtype from the start (page 1, offset 0). + $cursor['page_number'] = 1; + $cursor['offset_within_page'] = 0; + } + + $cursor['subtype_index'] = $s; + ++$s; + } // end for subtypes + + if ( ! $done ) { + // If we finished all subtypes in this provider, move to next provider and start at subtype=0, page=1. + $cursor['subtype_index'] = 0; + $cursor['page_number'] = 1; + $cursor['offset_within_page'] = 0; + } + + $cursor['provider_index'] = $p; + ++$p; + } // end for providers + + // Prepare next cursor. + $new_cursor = array( + 'provider_index' => $cursor['provider_index'], + 'subtype_index' => $cursor['subtype_index'], + 'page_number' => $cursor['page_number'], + 'offset_within_page' => $cursor['offset_within_page'], + 'batch_size' => $cursor['batch_size'], + ); + + return new WP_REST_Response( + array( + 'urls' => $all_urls, + 'cursor' => $new_cursor, + ) + ); +} + /** * Triggers actions for page caches to invalidate their caches related to the supplied cache purge post ID. * From bfed2503d28aed8a32c941f54615d1d72051bd3f Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 28 Jan 2025 01:16:45 +0530 Subject: [PATCH 04/62] Include breakpoints in URL processing --- .../prime-url-metrics.js | 44 ++++++++++++------- .../storage/rest-api.php | 20 ++++++++- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index eb9c25f9db..3c823366c3 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -46,18 +46,6 @@ isInitialized = true; iframe.style.display = 'block'; while ( isProcessing && isNextBatchAvailable ) { - /** - * @type {{ - * urls: Array, - * cursor: { - * provider_index: number, - * subtype_index: number, - * page_number: number, - * offset_within_page: number, - * batch_size: number - * } - * }} - */ const batch = await getBatch( cursor ); cursor = batch.cursor; progressBar.max += batch.urls.length; @@ -73,7 +61,22 @@ /** * Fetches the next batch of URLs. * @param {Object} lastCursor - The cursor to fetch the next batch. - * @return {Promise<{urls: string[], cursor: {provider_index: number, subtype_index: number, page_number: number, offset_within_page: number, batch_size: number}}>} The promise that resolves to the next batch of URLs. + * @return {Promise<{ + * urls: Array + * }>>, + * cursor: { + * provider_index: number, + * subtype_index: number, + * page_number: number, + * offset_within_page: number, + * batch_size: number + * } + * }>} - The promise that resolves to the batch of URLs. */ async function getBatch( lastCursor ) { const response = await apiFetch( { @@ -91,9 +94,18 @@ */ async function processBatch( urls ) { for ( const url of urls ) { - iframe.src = url; - await new Promise( ( resolve ) => { - iframe.onload = resolve; + await new Promise( async ( urlResolve ) => { + for ( const breakpoint of url.breakpoints ) { + await new Promise( ( breakpointResolve ) => { + iframe.src = url.url; + iframe.width = breakpoint.width; + iframe.height = breakpoint.height; + iframe.onload = () => { + breakpointResolve(); + }; + } ); + } + urlResolve(); } ); progressBar.value += 1; } diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index b7db2a8fe0..c1aa302163 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -330,6 +330,21 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). $done = false; + // Calculate breakpoints. + $widths = od_get_breakpoint_max_widths(); + $widths[] = (int) end( $widths ) + 300; // Add a large width. + + $calculated_breakpoints = array_map( + static function ( int $width ): array { + $aspect = max( 1, min( 2, $width / 1000 ) ); + return array( + 'width' => $width, + 'height' => round( $width / $aspect ), + ); + }, + $widths + ); + // Start iterating from the current provider_index forward. $providers_count = count( $providers ); for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { @@ -365,7 +380,10 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R // Now collect from current_page_urls until we reach $cursor['batch_size']. foreach ( $current_page_urls as $url ) { - $all_urls[] = $url; + $all_urls[] = array( + 'url' => $url, + 'breakpoints' => $calculated_breakpoints, + ); ++$collected_count; ++$consumed_in_this_page; From 6ec82e38e84f79770b9d3502512eb8cd4b4b856d Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 29 Jan 2025 00:51:14 +0530 Subject: [PATCH 05/62] Add debug mode and better dynamic height generation for breakpoints --- .../prime-url-metrics.js | 22 +++++++++--- plugins/optimization-detective/settings.php | 2 +- .../storage/rest-api.php | 35 +++++++++++++------ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 3c823366c3..73a6cae3bc 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -26,6 +26,7 @@ let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; + let isDebug = false; /** * Handles the prime URL metrics control button click. @@ -44,15 +45,22 @@ if ( ! isInitialized ) { isInitialized = true; - iframe.style.display = 'block'; + progressBar.max = 0; while ( isProcessing && isNextBatchAvailable ) { const batch = await getBatch( cursor ); - cursor = batch.cursor; - progressBar.max += batch.urls.length; if ( ! batch.urls.length ) { isNextBatchAvailable = false; break; } + isDebug = batch.isDebug; + cursor = batch.cursor; + + // As the progress bar max value is set to 1 initially, we need to update it to the actual value. + if ( 1 === progressBar.max ) { + progressBar.max = batch.urls.length; + } else { + progressBar.max += batch.urls.length; + } await processBatch( batch.urls ); } } @@ -75,7 +83,8 @@ * page_number: number, * offset_within_page: number, * batch_size: number - * } + * }, + * isDebug: boolean * }>} - The promise that resolves to the batch of URLs. */ async function getBatch( lastCursor ) { @@ -93,6 +102,11 @@ * @return {Promise} The promise that resolves to void. */ async function processBatch( urls ) { + if ( isDebug ) { + iframe.style.position = 'unset'; + iframe.style.transform = 'scale(0.5) translate(-50%, -50%)'; + iframe.style.visibility = 'visible'; + } for ( const url of urls ) { await new Promise( async ( urlResolve ) => { for ( const breakpoint of url.breakpoints ) { diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 46f0eb358d..4fbeba2225 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -42,7 +42,7 @@ function od_render_optimization_detective_page(): void {

- +
diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index c1aa302163..83bd799e5f 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -313,7 +313,7 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R 'subtype_index' => 0, 'page_number' => 1, 'offset_within_page' => 0, - 'batch_size' => 1, + 'batch_size' => 10, ) ); @@ -330,16 +330,28 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). $done = false; - // Calculate breakpoints. - $widths = od_get_breakpoint_max_widths(); - $widths[] = (int) end( $widths ) + 300; // Add a large width. + $widths = od_get_breakpoint_max_widths(); + sort( $widths ); + $min_width = $widths[0]; + $max_width = (int) end( $widths ) + 300; // For large screens. + $widths[] = $max_width; + + // We need to ensure min is 0.56 (1080/1920) else the height will become too small. + $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); + // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. + $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); + + $breakpoints = array_map( + static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { + // Linear interpolation between max_ar and min_ar based on width. + $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); + + // Clamp aspect ratio within bounds. + $ar = max( $min_ar, min( $max_ar, $ar ) ); - $calculated_breakpoints = array_map( - static function ( int $width ): array { - $aspect = max( 1, min( 2, $width / 1000 ) ); return array( 'width' => $width, - 'height' => round( $width / $aspect ), + 'height' => (int) round( $ar * $width ), ); }, $widths @@ -382,7 +394,7 @@ static function ( int $width ): array { foreach ( $current_page_urls as $url ) { $all_urls[] = array( 'url' => $url, - 'breakpoints' => $calculated_breakpoints, + 'breakpoints' => $breakpoints, ); ++$collected_count; ++$consumed_in_this_page; @@ -445,8 +457,9 @@ static function ( int $width ): array { return new WP_REST_Response( array( - 'urls' => $all_urls, - 'cursor' => $new_cursor, + 'urls' => $all_urls, + 'cursor' => $new_cursor, + 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ) ); } From af0172ef452e4243c7644c0e7e38fd3902d5bece Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 29 Jan 2025 23:41:20 +0530 Subject: [PATCH 06/62] Add verification token auth for protecting REST API, Add message passing between parent and iframe --- plugins/optimization-detective/detect.js | 65 +++++++++++++-- .../prime-url-metrics.js | 80 ++++++++++++++++--- .../storage/rest-api.php | 30 +++++-- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index cabcbe6c61..8f9bc47e58 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -26,6 +26,8 @@ const consoleLogPrefix = '[Optimization Detective]'; const storageLockTimeSessionKey = 'odStorageLockTime'; +let odPrimeUrlMetricsVerificationToken = ''; + /** * Checks whether storage is locked. * @@ -34,6 +36,10 @@ const storageLockTimeSessionKey = 'odStorageLockTime'; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { + if ( '' !== odPrimeUrlMetricsVerificationToken ) { + return false; + } + if ( storageLockTTL === 0 ) { return false; } @@ -344,6 +350,18 @@ export default async function detect( { } ); } + /** @type {HTMLIFrameElement|null} */ + const urlPrimeIframeElement = win.parent.document.querySelector( + 'iframe#od-prime-url-metrics-iframe' + ); + if ( + urlPrimeIframeElement && + urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken + ) { + odPrimeUrlMetricsVerificationToken = + urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken; + } + // TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition? // As an alternative to this, the od_print_detection_script() function can short-circuit if the // od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could @@ -578,6 +596,10 @@ export default async function detect( { // Wait for the page to be hidden. await new Promise( ( resolve ) => { + if ( '' !== odPrimeUrlMetricsVerificationToken ) { + resolve(); + } + win.addEventListener( 'pagehide', resolve, { once: true } ); win.addEventListener( 'pageswap', resolve, { once: true } ); doc.addEventListener( @@ -673,12 +695,45 @@ export default async function detect( { ); } url.searchParams.set( 'hmac', urlMetricHMAC ); - navigator.sendBeacon( - url, - new Blob( [ JSON.stringify( urlMetric ) ], { - type: 'application/json', + if ( '' !== odPrimeUrlMetricsVerificationToken ) { + url.searchParams.set( + 'prime_url_metrics_verification_token', + odPrimeUrlMetricsVerificationToken + ); + + fetch( url, { + method: 'POST', + body: JSON.stringify( urlMetric ), + headers: { + 'Content-Type': 'application/json', + }, } ) - ); + .then( ( response ) => { + if ( ! response.ok ) { + throw new Error( + `Failed to send URL Metric: ${ response.statusText }` + ); + } + window.parent.postMessage( + 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', + '*' + ); + } ) + .catch( ( err ) => { + window.parent.postMessage( + 'OD_PRIME_URL_METRICS_REQUEST_FAILURE', + '*' + ); + error( 'Failed to send URL Metric:', err ); + } ); + } else { + navigator.sendBeacon( + url, + new Blob( [ JSON.stringify( urlMetric ) ], { + type: 'application/json', + } ) + ); + } // Clean up. breadcrumbedElementsMap.clear(); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 73a6cae3bc..c391809610 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -27,6 +27,7 @@ let isNextBatchAvailable = true; let cursor = {}; let isDebug = false; + let verificationToken = ''; /** * Handles the prime URL metrics control button click. @@ -52,8 +53,9 @@ isNextBatchAvailable = false; break; } - isDebug = batch.isDebug; + verificationToken = batch.verificationToken; cursor = batch.cursor; + isDebug = batch.isDebug; // As the progress bar max value is set to 1 initially, we need to update it to the actual value. if ( 1 === progressBar.max ) { @@ -84,6 +86,7 @@ * offset_within_page: number, * batch_size: number * }, + * verificationToken: string, * isDebug: boolean * }>} - The promise that resolves to the batch of URLs. */ @@ -107,23 +110,74 @@ iframe.style.transform = 'scale(0.5) translate(-50%, -50%)'; iframe.style.visibility = 'visible'; } + for ( const url of urls ) { - await new Promise( async ( urlResolve ) => { - for ( const breakpoint of url.breakpoints ) { - await new Promise( ( breakpointResolve ) => { - iframe.src = url.url; - iframe.width = breakpoint.width; - iframe.height = breakpoint.height; - iframe.onload = () => { - breakpointResolve(); - }; - } ); + if ( ! isProcessing ) { + break; + } + + for ( const breakpoint of url.breakpoints ) { + if ( ! isProcessing ) { + break; } - urlResolve(); - } ); + + try { + iframe.dataset.odPrimeUrlMetricsVerificationToken = + verificationToken; + // Load iframe and wait for message + await loadIframeAndWaitForMessage( url.url, breakpoint ); + } catch ( error ) { + // TODO: Decide whether to retry or skip the URL. + } + } progressBar.value += 1; } } + /** + * Loads the iframe and waits for the message. + * @param {string} url - The URL to load in the iframe. + * @param {{width: number, height: number}} breakpoint - The breakpoint to set for the iframe. + * @return {Promise} The promise that resolves to void. + */ + function loadIframeAndWaitForMessage( url, breakpoint ) { + return new Promise( ( resolve, reject ) => { + const handleMessage = ( event ) => { + if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + cleanup(); + resolve(); + } else if ( + event.data === 'OD_PRIME_URL_METRICS_REQUEST_FAILURE' + ) { + cleanup(); + reject( new Error( 'Failed to send metrics' ) ); + } + }; + + const cleanup = () => { + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + }; + + const timeoutId = setTimeout( () => { + cleanup(); + reject( new Error( 'Timeout waiting for message' ) ); + }, 30000 ); // 30-second timeout + + window.addEventListener( 'message', handleMessage ); + + iframe.onerror = () => { + cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + // Load the iframe + iframe.src = url; + iframe.width = breakpoint.width.toString(); + iframe.height = breakpoint.height.toString(); + } ); + } + controlButton.addEventListener( 'click', handleControlButtonClick ); } )(); diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 83bd799e5f..06b88e3c5a 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -51,7 +51,7 @@ function od_register_endpoint(): void { // The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter, // they are provided as input with the 'url' argument to create the HMAC by the server. $args = array( - 'slug' => array( + 'slug' => array( 'type' => 'string', 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), 'required' => true, @@ -59,7 +59,7 @@ function od_register_endpoint(): void { 'minLength' => 32, 'maxLength' => 32, ), - 'current_etag' => array( + 'current_etag' => array( 'type' => 'string', 'description' => __( 'ETag for the current environment.', 'optimization-detective' ), 'required' => true, @@ -67,13 +67,13 @@ function od_register_endpoint(): void { 'minLength' => 32, 'maxLength' => 32, ), - 'cache_purge_post_id' => array( + 'cache_purge_post_id' => array( 'type' => 'integer', 'description' => __( 'Cache purge post ID.', 'optimization-detective' ), 'required' => false, 'minimum' => 1, ), - 'hmac' => array( + 'hmac' => array( 'type' => 'string', 'description' => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ), 'required' => true, @@ -85,6 +85,11 @@ function od_register_endpoint(): void { return true; }, ), + 'prime_url_metrics_verification_token' => array( + 'type' => 'string', + 'description' => __( 'Nonce for auto priming URLs.', 'optimization-detective' ), + 'required' => false, + ), ); register_rest_route( @@ -99,7 +104,13 @@ function od_register_endpoint(): void { 'callback' => static function ( WP_REST_Request $request ) { return od_handle_rest_request( $request ); }, - 'permission_callback' => static function () { + 'permission_callback' => static function ( WP_REST_Request $request ) { + // Authenticated requests when priming URL metrics through IFRAME. + $verification_token = $request->get_param( 'prime_url_metrics_verification_token' ); + if ( '' !== $verification_token && get_transient( 'od_prime_url_metrics_verification_token' ) === $verification_token ) { + return true; + } + // Needs to be available to unauthenticated visitors. if ( OD_Storage_Lock::is_locked() ) { return new WP_Error( @@ -455,11 +466,14 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { 'batch_size' => $cursor['batch_size'], ); + $verification_token = bin2hex( random_bytes( 16 ) ); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); return new WP_REST_Response( array( - 'urls' => $all_urls, - 'cursor' => $new_cursor, - 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'urls' => $all_urls, + 'cursor' => $new_cursor, + 'verificationToken' => $verification_token, + 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ) ); } From bff9973e6002203a0ca1ec07be99177a097fd3c5 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 30 Jan 2025 00:41:41 +0530 Subject: [PATCH 07/62] Refactor to improve control flow and add support for pause and resume on frontend --- .../prime-url-metrics.js | 155 +++++++++++------- 1 file changed, 92 insertions(+), 63 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index c391809610..d8510323d9 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -22,52 +22,109 @@ 'iframe#od-prime-url-metrics-iframe' ); - let isInitialized = false; let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; let isDebug = false; let verificationToken = ''; + let currentBatch = null; + let currentTasks = []; + let currentTaskIndex = 0; /** * Handles the prime URL metrics control button click. */ async function handleControlButtonClick() { if ( isProcessing ) { + // Pause processing + isProcessing = false; controlButton.textContent = __( 'Resume', 'optimization-detective' ); - isProcessing = false; } else { - controlButton.textContent = __( 'Pause', 'optimization-detective' ); + // Start/resume processing isProcessing = true; - } + controlButton.textContent = __( 'Pause', 'optimization-detective' ); - if ( ! isInitialized ) { - isInitialized = true; - progressBar.max = 0; - while ( isProcessing && isNextBatchAvailable ) { - const batch = await getBatch( cursor ); - if ( ! batch.urls.length ) { - isNextBatchAvailable = false; - break; + try { + while ( isProcessing ) { + if ( ! currentBatch ) { + currentBatch = await getBatch( cursor ); + if ( ! currentBatch.urls.length ) { + isNextBatchAvailable = false; + break; + } + + // Initialize batch state + verificationToken = currentBatch.verificationToken; + isDebug = currentBatch.isDebug; + currentTasks = flattenBatchToTasks( currentBatch ); + currentTaskIndex = 0; + progressBar.max = currentTasks.length; + progressBar.value = 0; + } + // Process tasks in current batch + while ( + isProcessing && + currentTaskIndex < currentTasks.length + ) { + await processTask( currentTasks[ currentTaskIndex ] ); + currentTaskIndex++; + progressBar.value = currentTaskIndex; + } + + if ( currentTaskIndex >= currentTasks.length ) { + // Complete current batch + cursor = currentBatch.cursor; + currentBatch = null; + currentTasks = []; + currentTaskIndex = 0; + } } - verificationToken = batch.verificationToken; - cursor = batch.cursor; - isDebug = batch.isDebug; - - // As the progress bar max value is set to 1 initially, we need to update it to the actual value. - if ( 1 === progressBar.max ) { - progressBar.max = batch.urls.length; - } else { - progressBar.max += batch.urls.length; + } catch ( error ) { + // TODO: Decide whether to skip the current task or stop processing. + isProcessing = false; + controlButton.textContent = __( + 'Click to retry', + 'optimization-detective' + ); + } finally { + if ( ! isNextBatchAvailable ) { + isProcessing = false; + controlButton.textContent = __( + 'Finished', + 'optimization-detective' + ); + controlButton.disabled = true; } - await processBatch( batch.urls ); } } } + /** + * Flattens the batch to tasks. + * @param {Object} batch - The batch to flatten. + * @return {Array<{ + * url: string, + * width: number, + * height: number + * }>} - The flattened tasks. + */ + function flattenBatchToTasks( batch ) { + const tasks = []; + for ( const url of batch.urls ) { + for ( const breakpoint of url.breakpoints ) { + tasks.push( { + url: url.url, + width: breakpoint.width, + height: breakpoint.height, + } ); + } + } + return tasks; + } + /** * Fetches the next batch of URLs. * @param {Object} lastCursor - The cursor to fetch the next batch. @@ -99,48 +156,12 @@ return response; } - /** - * Processes the batch of URLs. - * @param {Array} urls - The URLs to process - * @return {Promise} The promise that resolves to void. - */ - async function processBatch( urls ) { - if ( isDebug ) { - iframe.style.position = 'unset'; - iframe.style.transform = 'scale(0.5) translate(-50%, -50%)'; - iframe.style.visibility = 'visible'; - } - - for ( const url of urls ) { - if ( ! isProcessing ) { - break; - } - - for ( const breakpoint of url.breakpoints ) { - if ( ! isProcessing ) { - break; - } - - try { - iframe.dataset.odPrimeUrlMetricsVerificationToken = - verificationToken; - // Load iframe and wait for message - await loadIframeAndWaitForMessage( url.url, breakpoint ); - } catch ( error ) { - // TODO: Decide whether to retry or skip the URL. - } - } - progressBar.value += 1; - } - } - /** * Loads the iframe and waits for the message. - * @param {string} url - The URL to load in the iframe. - * @param {{width: number, height: number}} breakpoint - The breakpoint to set for the iframe. + * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. * @return {Promise} The promise that resolves to void. */ - function loadIframeAndWaitForMessage( url, breakpoint ) { + function processTask( task ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { @@ -173,9 +194,17 @@ }; // Load the iframe - iframe.src = url; - iframe.width = breakpoint.width.toString(); - iframe.height = breakpoint.height.toString(); + iframe.src = task.url; + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + iframe.dataset.odPrimeUrlMetricsVerificationToken = + verificationToken; + + if ( isDebug ) { + iframe.style.position = 'unset'; + iframe.style.transform = 'scale(0.5) translate(-50%, -50%)'; + iframe.style.visibility = 'visible'; + } } ); } From c155fc123193fc63fffd2ae0fae1ca1c4efb493d Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 30 Jan 2025 23:28:33 +0530 Subject: [PATCH 08/62] Move batch URL fetching logic to helper function --- plugins/optimization-detective/helper.php | 153 ++++++++++++++++++ .../storage/rest-api.php | 142 +--------------- 2 files changed, 156 insertions(+), 139 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 90d8f08f43..f122ad687c 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -139,3 +139,156 @@ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { ); } } + +/** + * Gets URLs for priming URL Metrics from sitemap in batches. + * + * @since n.e.x.t + * + * @param array $cursor Cursor to resume from. + * @return array Batch of URLs to prime metrics for and the updated cursor. + */ +function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { + // Get the server & its registry of sitemap providers. + $server = wp_sitemaps_get_server(); + $registry = $server->registry; + + // All registered providers. + $providers = array_values( $registry->get_providers() ); // Ensure zero-based index. + + $all_urls = array(); + $collected_count = 0; + + // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). + $done = false; + + $widths = od_get_breakpoint_max_widths(); + sort( $widths ); + $min_width = $widths[0]; + $max_width = (int) end( $widths ) + 300; // For large screens. + $widths[] = $max_width; + + // We need to ensure min is 0.56 (1080/1920) else the height will become too small. + $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); + // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. + $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); + + $breakpoints = array_map( + static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { + // Linear interpolation between max_ar and min_ar based on width. + $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); + + // Clamp aspect ratio within bounds. + $ar = max( $min_ar, min( $max_ar, $ar ) ); + + return array( + 'width' => $width, + 'height' => (int) round( $ar * $width ), + ); + }, + $widths + ); + + // Start iterating from the current provider_index forward. + $providers_count = count( $providers ); + for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { + $provider = $providers[ $p ]; + + // WordPress providers return an array of strings from get_object_subtypes(). + $subtypes = array_values( $provider->get_object_subtypes() ); // zero-based index. + + // Start from the current subtype_index if resuming. + $subtypes_count = count( $subtypes ); + for ( $s = ( $p === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $s < $subtypes_count && ! $done; ) { + // This is a string, e.g. 'post', 'page', etc. + $subtype = $subtypes[ $s ]; + + // Retrieve the max number of pages for this subtype. + $max_num_pages = $provider->get_max_num_pages( $subtype->name ); + + // Start from the current page_number if resuming. + for ( $page = ( ( $p === $cursor['provider_index'] ) && ( $s === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) { + $url_list = $provider->get_url_list( $page, $subtype->name ); + if ( ! is_array( $url_list ) ) { + continue; + } + + // Filter out empty URLs. + $url_chunk = array_filter( array_column( $url_list, 'loc' ) ); + + // We might have partially consumed this page, so skip $cursor['offset_within_page'] items first. + $current_page_urls = array_slice( $url_chunk, $cursor['offset_within_page'] ); + + // Count how many URLs we consumed in this page. + $consumed_in_this_page = 0; + + // Now collect from current_page_urls until we reach $cursor['batch_size']. + foreach ( $current_page_urls as $url ) { + $all_urls[] = array( + 'url' => $url, + 'breakpoints' => $breakpoints, + ); + ++$collected_count; + ++$consumed_in_this_page; + + if ( $collected_count >= $cursor['batch_size'] ) { + // We have our full batch; stop collecting further. + $done = true; + break; + } + } + + if ( ! $done ) { + // We consumed this entire page, so if we continue, next time we start at offset 0 of the next page. + $cursor['page_number'] = $page + 1; + $cursor['offset_within_page'] = 0; + } else { + // We reached the limit in the middle of this page. + // Figure out how many we used from this page to update the offset properly. + $extra_consumed = $collected_count - $cursor['batch_size']; // If exactly $cursor['batch_size'], this might be 0 or negative. + if ( $extra_consumed < 0 ) { + $extra_consumed = 0; + } + + $cursor['offset_within_page'] = $cursor['offset_within_page'] + ( $consumed_in_this_page - $extra_consumed ); + + // We haven't fully finished this page, so keep the same $cursor['page_number']. + $cursor['page_number'] = $page; + } + } // end for pages + + if ( ! $done ) { + // If we've finished all pages in this subtype, move to next subtype from the start (page 1, offset 0). + $cursor['page_number'] = 1; + $cursor['offset_within_page'] = 0; + } + + $cursor['subtype_index'] = $s; + ++$s; + } // end for subtypes + + if ( ! $done ) { + // If we finished all subtypes in this provider, move to next provider and start at subtype=0, page=1. + $cursor['subtype_index'] = 0; + $cursor['page_number'] = 1; + $cursor['offset_within_page'] = 0; + } + + $cursor['provider_index'] = $p; + ++$p; + } // end for providers + + // Prepare next cursor. + $new_cursor = array( + 'provider_index' => $cursor['provider_index'], + 'subtype_index' => $cursor['subtype_index'], + 'page_number' => $cursor['page_number'], + 'offset_within_page' => $cursor['offset_within_page'], + 'batch_size' => $cursor['batch_size'], + ); + + return array( + 'urls' => $all_urls, + 'cursor' => $new_cursor, + ); +} diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 06b88e3c5a..cbaefddb48 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -328,150 +328,14 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R ) ); - // Get the server & its registry of sitemap providers. - $server = wp_sitemaps_get_server(); - $registry = $server->registry; - - // All registered providers. - $providers = array_values( $registry->get_providers() ); // Ensure zero-based index. - - $all_urls = array(); - $collected_count = 0; - - // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). - $done = false; - - $widths = od_get_breakpoint_max_widths(); - sort( $widths ); - $min_width = $widths[0]; - $max_width = (int) end( $widths ) + 300; // For large screens. - $widths[] = $max_width; - - // We need to ensure min is 0.56 (1080/1920) else the height will become too small. - $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); - // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. - $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); - - $breakpoints = array_map( - static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { - // Linear interpolation between max_ar and min_ar based on width. - $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); - - // Clamp aspect ratio within bounds. - $ar = max( $min_ar, min( $max_ar, $ar ) ); - - return array( - 'width' => $width, - 'height' => (int) round( $ar * $width ), - ); - }, - $widths - ); - - // Start iterating from the current provider_index forward. - $providers_count = count( $providers ); - for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { - $provider = $providers[ $p ]; - - // WordPress providers return an array of strings from get_object_subtypes(). - $subtypes = array_values( $provider->get_object_subtypes() ); // zero-based index. - - // Start from the current subtype_index if resuming. - $subtypes_count = count( $subtypes ); - for ( $s = ( $p === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $s < $subtypes_count && ! $done; ) { - // This is a string, e.g. 'post', 'page', etc. - $subtype = $subtypes[ $s ]; - - // Retrieve the max number of pages for this subtype. - $max_num_pages = $provider->get_max_num_pages( $subtype->name ); - - // Start from the current page_number if resuming. - for ( $page = ( ( $p === $cursor['provider_index'] ) && ( $s === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) { - $url_list = $provider->get_url_list( $page, $subtype->name ); - if ( ! is_array( $url_list ) ) { - continue; - } - - // Filter out empty URLs. - $url_chunk = array_filter( array_column( $url_list, 'loc' ) ); - - // We might have partially consumed this page, so skip $cursor['offset_within_page'] items first. - $current_page_urls = array_slice( $url_chunk, $cursor['offset_within_page'] ); - - // Count how many URLs we consumed in this page. - $consumed_in_this_page = 0; - - // Now collect from current_page_urls until we reach $cursor['batch_size']. - foreach ( $current_page_urls as $url ) { - $all_urls[] = array( - 'url' => $url, - 'breakpoints' => $breakpoints, - ); - ++$collected_count; - ++$consumed_in_this_page; - - if ( $collected_count >= $cursor['batch_size'] ) { - // We have our full batch; stop collecting further. - $done = true; - break; - } - } - - if ( ! $done ) { - // We consumed this entire page, so if we continue, next time we start at offset 0 of the next page. - $cursor['page_number'] = $page + 1; - $cursor['offset_within_page'] = 0; - } else { - // We reached the limit in the middle of this page. - // Figure out how many we used from this page to update the offset properly. - $extra_consumed = $collected_count - $cursor['batch_size']; // If exactly $cursor['batch_size'], this might be 0 or negative. - if ( $extra_consumed < 0 ) { - $extra_consumed = 0; - } - - $cursor['offset_within_page'] = $cursor['offset_within_page'] + ( $consumed_in_this_page - $extra_consumed ); - - // We haven't fully finished this page, so keep the same $cursor['page_number']. - $cursor['page_number'] = $page; - } - } // end for pages - - if ( ! $done ) { - // If we've finished all pages in this subtype, move to next subtype from the start (page 1, offset 0). - $cursor['page_number'] = 1; - $cursor['offset_within_page'] = 0; - } - - $cursor['subtype_index'] = $s; - ++$s; - } // end for subtypes - - if ( ! $done ) { - // If we finished all subtypes in this provider, move to next provider and start at subtype=0, page=1. - $cursor['subtype_index'] = 0; - $cursor['page_number'] = 1; - $cursor['offset_within_page'] = 0; - } - - $cursor['provider_index'] = $p; - ++$p; - } // end for providers - - // Prepare next cursor. - $new_cursor = array( - 'provider_index' => $cursor['provider_index'], - 'subtype_index' => $cursor['subtype_index'], - 'page_number' => $cursor['page_number'], - 'offset_within_page' => $cursor['offset_within_page'], - 'batch_size' => $cursor['batch_size'], - ); + $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); $verification_token = bin2hex( random_bytes( 16 ) ); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); return new WP_REST_Response( array( - 'urls' => $all_urls, - 'cursor' => $new_cursor, + 'urls' => $batch['urls'], + 'cursor' => $batch['cursor'], 'verificationToken' => $verification_token, 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ) From 7640ef780f79512b52ec66f4971ab285e854939b Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 31 Jan 2025 02:31:37 +0530 Subject: [PATCH 09/62] Add support to filter URL whose metrics are already populated --- plugins/optimization-detective/helper.php | 105 ++++++++++++------ .../prime-url-metrics.js | 6 +- .../storage/rest-api.php | 56 +++++++++- 3 files changed, 132 insertions(+), 35 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index f122ad687c..954e68db76 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -162,33 +162,6 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { // Flag to indicate if we should stop collecting further URLs (i.e., we reached $cursor['batch_size']). $done = false; - $widths = od_get_breakpoint_max_widths(); - sort( $widths ); - $min_width = $widths[0]; - $max_width = (int) end( $widths ) + 300; // For large screens. - $widths[] = $max_width; - - // We need to ensure min is 0.56 (1080/1920) else the height will become too small. - $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); - // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. - $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); - - $breakpoints = array_map( - static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { - // Linear interpolation between max_ar and min_ar based on width. - $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); - - // Clamp aspect ratio within bounds. - $ar = max( $min_ar, min( $max_ar, $ar ) ); - - return array( - 'width' => $width, - 'height' => (int) round( $ar * $width ), - ); - }, - $widths - ); - // Start iterating from the current provider_index forward. $providers_count = count( $providers ); for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { @@ -224,10 +197,7 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { // Now collect from current_page_urls until we reach $cursor['batch_size']. foreach ( $current_page_urls as $url ) { - $all_urls[] = array( - 'url' => $url, - 'breakpoints' => $breakpoints, - ); + $all_urls[] = $url; ++$collected_count; ++$consumed_in_this_page; @@ -292,3 +262,76 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { 'cursor' => $new_cursor, ); } + +/** + * Filter for WP_Query to allow specifying 'post_title__in' => array( 'title1', 'title2', ... ). + * + * @param string $where The WHERE clause of the query. + * @param WP_Query $query The WP_Query instance. + */ +function od_filter_posts_where_for_titles( string $where, WP_Query $query ): string { + global $wpdb; + + $titles = (array) $query->get( 'post_title__in', array() ); + $titles = array_filter( $titles ); + + if ( 0 === count( $titles ) ) { + return $where; + } + + // Safely prepare each title for IN() clause. + $placeholders = array(); + foreach ( $titles as $title ) { + $placeholders[] = $wpdb->prepare( '%s', $title ); + } + $list = implode( ',', $placeholders ); + + $where .= " AND {$wpdb->posts}.post_title IN ($list)"; + return $where; +} + +/** + * Fetches od_url_metrics posts of URLs in a single WP_Query. + * + * @param string[] $urls Array of exact URLs, as stored in post_title of od_url_metrics. + * @return array Map of URL to its OD_URL_Metric_Group_Collection. + */ +function od_get_metrics_by_post_title_using_wp_query( array $urls ): array { + $urls = array_unique( array_filter( $urls ) ); + if ( 0 === count( $urls ) ) { + return array(); + } + + $results_map = array(); + + add_filter( 'posts_where', 'od_filter_posts_where_for_titles', 10, 2 ); + + $query = new WP_Query( + array( + 'post_type' => OD_URL_Metrics_Post_Type::SLUG, + 'post_status' => 'publish', + 'post_title__in' => $urls, + 'posts_per_page' => -1, + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'fields' => 'all', + ) + ); + + remove_filter( 'posts_where', 'od_filter_posts_where_for_titles', 10 ); + + foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } + $results_map[ $post->post_title ] = new OD_URL_Metric_Group_Collection( + OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ), + md5( '' ), // This is a dummy hash. + od_get_breakpoint_max_widths(), + od_get_url_metrics_breakpoint_sample_size(), + od_get_url_metric_freshness_ttl() + ); + } + return $results_map; +} diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index d8510323d9..e3c204020c 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -51,7 +51,7 @@ while ( isProcessing ) { if ( ! currentBatch ) { currentBatch = await getBatch( cursor ); - if ( ! currentBatch.urls.length ) { + if ( ! currentBatch.batch.length ) { isNextBatchAvailable = false; break; } @@ -113,7 +113,7 @@ */ function flattenBatchToTasks( batch ) { const tasks = []; - for ( const url of batch.urls ) { + for ( const url of batch.batch ) { for ( const breakpoint of url.breakpoints ) { tasks.push( { url: url.url, @@ -129,7 +129,7 @@ * Fetches the next batch of URLs. * @param {Object} lastCursor - The cursor to fetch the next batch. * @return {Promise<{ - * urls: Array $width, + 'height' => (int) round( $ar * $width ), + ); + }, + $widths + ); + + // Filter out any URL Metrics that are already complete. + $batch['urls'] = array_filter( + $batch['urls'], + static function ( $url ) use ( $group_collections ) { + $group_collection = $group_collections[ $url ] ?? null; + if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { + return true; + } + + return ! $group_collection->is_every_group_populated(); + } + ); + + $batch_with_breakpoints = array_values( + array_map( + static function ( $url ) use ( $breakpoints ) { + return array( + 'url' => $url, + 'breakpoints' => $breakpoints, + ); + }, + $batch['urls'] + ) + ); + $verification_token = bin2hex( random_bytes( 16 ) ); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); return new WP_REST_Response( array( - 'urls' => $batch['urls'], + 'batch' => $batch_with_breakpoints, 'cursor' => $batch['cursor'], 'verificationToken' => $verification_token, 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, From f05f6c03c476664f782dab330bbf42dd43c7ead7 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 31 Jan 2025 22:20:59 +0530 Subject: [PATCH 10/62] Add support to filter breakpoints whose metrics are already populated --- .../storage/rest-api.php | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index e1ca17751a..9c72ec97f2 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -343,7 +343,7 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); - $breakpoints = array_map( + $standard_breakpoints = array_map( static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { // Linear interpolation between max_ar and min_ar based on width. $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); @@ -359,30 +359,46 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { $widths ); + $batch_with_breakpoints = array(); + // Filter out any URL Metrics that are already complete. - $batch['urls'] = array_filter( - $batch['urls'], - static function ( $url ) use ( $group_collections ) { - $group_collection = $group_collections[ $url ] ?? null; - if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { - return true; + foreach ( $batch['urls'] as $url ) { + $group_collection = $group_collections[ $url ] ?? null; + if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { + $batch_with_breakpoints[] = array( + 'url' => $url, + 'breakpoints' => $standard_breakpoints, + ); + continue; + } + + if ( $group_collection->is_every_group_populated() ) { + continue; + } + + $existing_widths = array(); + foreach ( $group_collection as $group ) { + if ( ! $group->is_complete() ) { + foreach ( $group as $url_metric ) { + $existing_widths[] = $url_metric->get_viewport_width(); + } } + } - return ! $group_collection->is_every_group_populated(); + $missing_breakpoints = array(); + foreach ( $standard_breakpoints as $breakpoint ) { + if ( ! in_array( $breakpoint['width'], $existing_widths, true ) ) { + $missing_breakpoints[] = $breakpoint; + } } - ); - $batch_with_breakpoints = array_values( - array_map( - static function ( $url ) use ( $breakpoints ) { - return array( - 'url' => $url, - 'breakpoints' => $breakpoints, - ); - }, - $batch['urls'] - ) - ); + if ( count( $missing_breakpoints ) > 0 ) { + $batch_with_breakpoints[] = array( + 'url' => $url, + 'breakpoints' => $missing_breakpoints, + ); + } + } $verification_token = bin2hex( random_bytes( 16 ) ); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); From 0631daf3e101b75c0c95929ba5a175d9ffe174f6 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 31 Jan 2025 22:49:07 +0530 Subject: [PATCH 11/62] Refactor by moving filtering batch URls and computing standard brekpoints logic to dedicated functions --- plugins/optimization-detective/helper.php | 88 ++++++++++++++++++- .../storage/rest-api.php | 75 +--------------- 2 files changed, 90 insertions(+), 73 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 954e68db76..418644ef81 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -296,7 +296,7 @@ function od_filter_posts_where_for_titles( string $where, WP_Query $query ): str * @param string[] $urls Array of exact URLs, as stored in post_title of od_url_metrics. * @return array Map of URL to its OD_URL_Metric_Group_Collection. */ -function od_get_metrics_by_post_title_using_wp_query( array $urls ): array { +function od_get_metrics_by_post_title( array $urls ): array { $urls = array_unique( array_filter( $urls ) ); if ( 0 === count( $urls ) ) { return array(); @@ -335,3 +335,89 @@ function od_get_metrics_by_post_title_using_wp_query( array $urls ): array { } return $results_map; } + +/** + * Computes the standard array of breakpoints. + * + * @return array Array of breakpoints. + */ +function od_get_standard_breakpoints(): array { + $widths = od_get_breakpoint_max_widths(); + sort( $widths ); + + $min_width = $widths[0]; + $max_width = (int) end( $widths ) + 300; // For large screens. + $widths[] = $max_width; + + // We need to ensure min is 0.56 (1080/1920) else the height becomes too small. + $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); + // Ensure max is 1.78 (1920/1080) else the height becomes too large. + $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); + + // Compute [width => height] for each breakpoint. + return array_map( + static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { + // Linear interpolation between max_ar and min_ar based on width. + $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); + $ar = max( $min_ar, min( $max_ar, $ar ) ); + + return array( + 'width' => $width, + 'height' => (int) round( $ar * $width ), + ); + }, + $widths + ); +} + +/** + * Filters the batch of URLs to only include those that need additional metrics. + * + * @param array $urls Array of URLs to filter. + * @return array}> Filtered batch of URLs. + */ +function od_filter_batch_urls_for_iframe_url_metrics_priming( array $urls ): array { + $filtered_batch = array(); + $standard_breakpoints = od_get_standard_breakpoints(); + $group_collections = od_get_metrics_by_post_title( $urls ); + + foreach ( $urls as $url ) { + $group_collection = $group_collections[ $url ] ?? null; + if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { + $filtered_batch[] = array( + 'url' => $url, + 'breakpoints' => $standard_breakpoints, + ); + continue; + } + + if ( $group_collection->is_every_group_populated() ) { + continue; + } + + $existing_widths = array(); + foreach ( $group_collection as $group ) { + if ( ! $group->is_complete() ) { + foreach ( $group as $url_metric ) { + $existing_widths[] = $url_metric->get_viewport_width(); + } + } + } + + $missing_breakpoints = array(); + foreach ( $standard_breakpoints as $breakpoint ) { + if ( ! in_array( $breakpoint['width'], $existing_widths, true ) ) { + $missing_breakpoints[] = $breakpoint; + } + } + + if ( count( $missing_breakpoints ) > 0 ) { + $filtered_batch[] = array( + 'url' => $url, + 'breakpoints' => $missing_breakpoints, + ); + } + } + + return $filtered_batch; +} diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 9c72ec97f2..d89b997db5 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -328,83 +328,14 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R ) ); - $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); - - $group_collections = od_get_metrics_by_post_title_using_wp_query( $batch['urls'] ); - - $widths = od_get_breakpoint_max_widths(); - sort( $widths ); - $min_width = $widths[0]; - $max_width = (int) end( $widths ) + 300; // For large screens. - $widths[] = $max_width; - - // We need to ensure min is 0.56 (1080/1920) else the height will become too small. - $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); - // We also need to ensure max is 1.78 (1920/1080) else the height will become too large. - $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); - - $standard_breakpoints = array_map( - static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { - // Linear interpolation between max_ar and min_ar based on width. - $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); - - // Clamp aspect ratio within bounds. - $ar = max( $min_ar, min( $max_ar, $ar ) ); - - return array( - 'width' => $width, - 'height' => (int) round( $ar * $width ), - ); - }, - $widths - ); - - $batch_with_breakpoints = array(); - - // Filter out any URL Metrics that are already complete. - foreach ( $batch['urls'] as $url ) { - $group_collection = $group_collections[ $url ] ?? null; - if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { - $batch_with_breakpoints[] = array( - 'url' => $url, - 'breakpoints' => $standard_breakpoints, - ); - continue; - } - - if ( $group_collection->is_every_group_populated() ) { - continue; - } - - $existing_widths = array(); - foreach ( $group_collection as $group ) { - if ( ! $group->is_complete() ) { - foreach ( $group as $url_metric ) { - $existing_widths[] = $url_metric->get_viewport_width(); - } - } - } - - $missing_breakpoints = array(); - foreach ( $standard_breakpoints as $breakpoint ) { - if ( ! in_array( $breakpoint['width'], $existing_widths, true ) ) { - $missing_breakpoints[] = $breakpoint; - } - } - - if ( count( $missing_breakpoints ) > 0 ) { - $batch_with_breakpoints[] = array( - 'url' => $url, - 'breakpoints' => $missing_breakpoints, - ); - } - } + $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); + $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); $verification_token = bin2hex( random_bytes( 16 ) ); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); return new WP_REST_Response( array( - 'batch' => $batch_with_breakpoints, + 'batch' => $filtered_batch_urls, 'cursor' => $batch['cursor'], 'verificationToken' => $verification_token, 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, From 5d93fdc33487ad9d2b749359d08ccae474dcbc99 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 31 Jan 2025 23:05:50 +0530 Subject: [PATCH 12/62] Fix sending empty batch URLs caused by filtering the batch URLs --- .../optimization-detective/storage/rest-api.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index d89b997db5..f32298192f 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -328,8 +328,19 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R ) ); - $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); - $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + $batch = array(); + $filtered_batch_urls = array(); + $prevent_infinite = 0; + while ( $prevent_infinite < 100 ) { + if ( count( $filtered_batch_urls ) > 0 ) { + break; + } + + $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); + $cursor = $batch['cursor']; + $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + ++$prevent_infinite; + } $verification_token = bin2hex( random_bytes( 16 ) ); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); From 3118fb368f73ea608f7989b713ea4d3e18f88e9c Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 31 Jan 2025 23:37:48 +0530 Subject: [PATCH 13/62] Save last batch cursor in options table to improve performance --- .../storage/rest-api.php | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index f32298192f..6a82e6397f 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -316,18 +316,26 @@ function od_handle_rest_request( WP_REST_Request $request ) { function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_REST_Response { $cursor = $request->get_param( 'cursor' ); - // Initialize cursor with default values. - $cursor = wp_parse_args( - $cursor, - array( - 'provider_index' => 0, - 'subtype_index' => 0, - 'page_number' => 1, - 'offset_within_page' => 0, - 'batch_size' => 10, - ) + $default_cursor = array( + 'provider_index' => 0, + 'subtype_index' => 0, + 'page_number' => 1, + 'offset_within_page' => 0, + 'batch_size' => 10, ); + // Initialize cursor with default values. + $cursor = wp_parse_args( $cursor, $default_cursor ); + + if ( $default_cursor === $cursor ) { + $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); + if ( false !== $last_cursor ) { + $cursor = wp_parse_args( $last_cursor, $cursor ); + } + } else { + update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); + } + $batch = array(); $filtered_batch_urls = array(); $prevent_infinite = 0; @@ -337,8 +345,14 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R } $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); - $cursor = $batch['cursor']; $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + + if ( $cursor === $batch['cursor'] ) { + delete_option( 'od_prime_url_metrics_batch_cursor' ); + break; + } + $cursor = $batch['cursor']; + ++$prevent_infinite; } From d8cfa02028e3711deff4a0c41f61c6538595b2aa Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 3 Feb 2025 12:59:40 +0530 Subject: [PATCH 14/62] Improve verification token handling by using a transient check before generating a new UUID --- plugins/optimization-detective/storage/rest-api.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 6a82e6397f..0a6e393fad 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -356,8 +356,13 @@ function od_handle_generate_batch_urls_request( WP_REST_Request $request ): WP_R ++$prevent_infinite; } - $verification_token = bin2hex( random_bytes( 16 ) ); - set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + + if ( false === $verification_token ) { + $verification_token = wp_generate_uuid4(); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + return new WP_REST_Response( array( 'batch' => $filtered_batch_urls, From 8879f60e0d16668158ae9bb61297942fc4f70098 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 3 Feb 2025 13:38:25 +0530 Subject: [PATCH 15/62] Add beforeunload event listener to prevent page navigation during processing --- plugins/optimization-detective/prime-url-metrics.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index e3c204020c..87c79c72da 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -1,7 +1,6 @@ /** * Helper script for the Prime URL Metrics. */ - ( function () { // @ts-ignore const { i18n, apiFetch } = wp; @@ -209,4 +208,13 @@ } controlButton.addEventListener( 'click', handleControlButtonClick ); + + /** + * Prevent the user from leaving the page while processing. + */ + window.addEventListener( 'beforeunload', function ( event ) { + if ( isProcessing ) { + event.preventDefault(); + } + } ); } )(); From 72b18fdd1636ac5ce5d50a49488183634b4663c3 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 3 Feb 2025 18:50:53 +0530 Subject: [PATCH 16/62] Add new REST API routes for URL metrics breakpoints and verification token --- .../storage/rest-api.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 0a6e393fad..4e260f9f28 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -38,6 +38,20 @@ */ const OD_PRIME_URLS_ROUTE = '/prime-urls'; +/** + * Route for getting breakpoints for URL Metrics. + * + * @var string + */ +const OD_PRIME_URLS_BREAKPOINTS_ROUTE = '/prime-urls-breakpoints'; + +/** + * Route for verifying the token for auto priming URLs. + * + * @var string + */ +const OD_PRIME_URLS_VERIFICATION_TOKEN_ROUTE = '/prime-urls-verification-token'; + /** * Registers endpoint for storage of URL Metric. * @@ -137,6 +151,39 @@ function od_register_endpoint(): void { }, ) ); + + register_rest_route( + OD_REST_API_NAMESPACE, + OD_PRIME_URLS_BREAKPOINTS_ROUTE, + array( + 'methods' => 'GET', + 'callback' => static function () { + return new WP_REST_Response( od_get_standard_breakpoints() ); + }, + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + + register_rest_route( + OD_REST_API_NAMESPACE, + OD_PRIME_URLS_VERIFICATION_TOKEN_ROUTE, + array( + 'methods' => 'GET', + 'callback' => static function () { + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + if ( false === $verification_token ) { + $verification_token = wp_generate_uuid4(); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + return new WP_REST_Response( $verification_token ); + }, + 'permission_callback' => static function () { + return current_user_can( 'manage_options' ); + }, + ) + ); } add_action( 'rest_api_init', 'od_register_endpoint' ); From f5bba487efc16048de192eb90e73ad53c4a71032 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 3 Feb 2025 18:51:36 +0530 Subject: [PATCH 17/62] Add block editor support for priming URL metrics --- plugins/optimization-detective/helper.php | 29 +++++ plugins/optimization-detective/hooks.php | 1 + .../prime-url-metrics-block-editor.js | 120 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 plugins/optimization-detective/prime-url-metrics-block-editor.js diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 418644ef81..f8b8933b68 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -126,6 +126,9 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string /** * Enqueues admin scripts. * + * @since n.e.x.t + * @access private + * * @param string $hook_suffix Current admin page. */ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { @@ -140,10 +143,27 @@ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { } } +/** + * Adds the Optimization Detective menu to the admin menu. + * + * @since 0.1.0 + * @access private + */ +function od_enqueue_block_editor_prime_url_metrics_scripts(): void { + wp_enqueue_script( + 'od-prime-url-metrics', + plugins_url( od_get_asset_path( 'prime-url-metrics-block-editor.js' ), __FILE__ ), + array( 'wp-data', 'wp-api-fetch' ), + OPTIMIZATION_DETECTIVE_VERSION, + true + ); +} + /** * Gets URLs for priming URL Metrics from sitemap in batches. * * @since n.e.x.t + * @access private * * @param array $cursor Cursor to resume from. * @return array Batch of URLs to prime metrics for and the updated cursor. @@ -266,6 +286,9 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { /** * Filter for WP_Query to allow specifying 'post_title__in' => array( 'title1', 'title2', ... ). * + * @since n.e.x.t + * @access private + * * @param string $where The WHERE clause of the query. * @param WP_Query $query The WP_Query instance. */ @@ -339,6 +362,9 @@ function od_get_metrics_by_post_title( array $urls ): array { /** * Computes the standard array of breakpoints. * + * @since n.e.x.t + * @access private + * * @return array Array of breakpoints. */ function od_get_standard_breakpoints(): array { @@ -373,6 +399,9 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { /** * Filters the batch of URLs to only include those that need additional metrics. * + * @since n.e.x.t + * @access private + * * @param array $urls Array of URLs to filter. * @return array}> Filtered batch of URLs. */ diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index dcf362fe54..87a7e7f6bb 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -25,4 +25,5 @@ add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 ); add_action( 'admin_menu', 'od_add_optimization_detective_menu' ); add_action( 'admin_enqueue_scripts', 'od_enqueue_prime_url_metrics_scripts' ); +add_action( 'enqueue_block_editor_assets', 'od_enqueue_block_editor_prime_url_metrics_scripts' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js new file mode 100644 index 0000000000..e470eb24a5 --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -0,0 +1,120 @@ +/** + * Helper script for the Priming URL Metrics in block editor. + */ +( function () { + // @ts-ignore + const { select, subscribe } = wp.data; + // @ts-ignore + const { apiFetch } = wp; + + let isProcessing = false; + let breakpoints = []; + const iframe = document.createElement( 'iframe' ); + iframe.id = 'od-prime-url-metrics-iframe'; + iframe.style.display = 'block'; + iframe.style.position = 'absolute'; + iframe.style.transform = 'translateX(-9999px)'; + iframe.style.visibility = 'hidden'; + document.body.appendChild( iframe ); + + /** + * Primes the URL metrics for all breakpoints. + * @return {Promise} The promise that resolves to void. + */ + async function primeURL() { + isProcessing = true; + try { + if ( 0 === breakpoints.length ) { + breakpoints = await apiFetch( { + path: '/optimization-detective/v1/prime-urls-breakpoints', + method: 'GET', + } ); + } + + const postURL = select( 'core/editor' ).getPermalink(); + const verificationToken = await apiFetch( { + path: '/optimization-detective/v1/prime-urls-verification-token', + method: 'GET', + } ); + + for ( const breakpoint of breakpoints ) { + await processTask( { + url: postURL, + width: breakpoint.width, + height: breakpoint.height, + verificationToken, + } ); + } + isProcessing = false; + } catch ( error ) { + isProcessing = false; + } + } + + /** + * Loads the iframe and waits for the message. + * @param {{url: string, width: number, height: number, verificationToken: string}} task - The breakpoint to set for the iframe. + * @return {Promise} The promise that resolves to void. + */ + function processTask( task ) { + return new Promise( ( resolve, reject ) => { + const handleMessage = ( event ) => { + if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + cleanup(); + resolve(); + } else if ( + event.data === 'OD_PRIME_URL_METRICS_REQUEST_FAILURE' + ) { + cleanup(); + reject( new Error( 'Failed to send metrics' ) ); + } + }; + + const cleanup = () => { + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + }; + + const timeoutId = setTimeout( () => { + cleanup(); + reject( new Error( 'Timeout waiting for message' ) ); + }, 30000 ); // 30-second timeout + + window.addEventListener( 'message', handleMessage ); + + iframe.onerror = () => { + cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + // Load the iframe + iframe.src = task.url; + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + iframe.dataset.odPrimeUrlMetricsVerificationToken = + task.verificationToken; + } ); + } + + // Listen for post save/publish events + subscribe( () => { + const isSaving = select( 'core/editor' ).isSavingPost(); + const isAutosaving = select( 'core/editor' ).isAutosavingPost(); + const isPublished = + select( 'core/editor' ).getCurrentPost().status === 'publish'; + const isJustSaved = isSaving && ! isAutosaving && isPublished; + if ( isJustSaved ) { + primeURL(); + } + } ); + + /** + * Prevent the user from leaving the page while processing. + */ + window.addEventListener( 'beforeunload', function ( event ) { + if ( isProcessing ) { + event.preventDefault(); + } + } ); +} )(); From db1fd815145ba5513f73d6ae477a4e231194f3f3 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 4 Feb 2025 14:59:13 +0530 Subject: [PATCH 18/62] Add missing since and access annotations, Add new JavaScripts to webpack config --- plugins/optimization-detective/helper.php | 5 ++++- webpack.config.js | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index ee800723eb..ab92862afd 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -146,7 +146,7 @@ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { /** * Adds the Optimization Detective menu to the admin menu. * - * @since 0.1.0 + * @since n.e.x.t * @access private */ function od_enqueue_block_editor_prime_url_metrics_scripts(): void { @@ -316,6 +316,9 @@ function od_filter_posts_where_for_titles( string $where, WP_Query $query ): str /** * Fetches od_url_metrics posts of URLs in a single WP_Query. * + * @since n.e.x.t + * @access private + * * @param string[] $urls Array of exact URLs, as stored in post_title of od_url_metrics. * @return array Map of URL to its OD_URL_Metric_Group_Collection. */ diff --git a/webpack.config.js b/webpack.config.js index 21f6a43662..c0f590274e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -207,6 +207,14 @@ const optimizationDetective = ( env ) => { from: `${ destination }/detect.js`, to: `${ destination }/detect.min.js`, }, + { + from: `${ destination }/prime-url-metrics.js`, + to: `${ destination }/prime-url-metrics.min.js`, + }, + { + from: `${ destination }/prime-url-metrics-block-editor.js`, + to: `${ destination }/prime-url-metrics-block-editor.min.js`, + }, ], } ), new WebpackBar( { From 63564c458df4c0aa98eec61c61af8b694b9700cc Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 4 Feb 2025 22:29:18 +0530 Subject: [PATCH 19/62] Remove usage of fetch and Fix URL metrics not priming due to usage of visibility hidden --- plugins/optimization-detective/detect.js | 42 +++++-------------- .../prime-url-metrics-block-editor.js | 1 - plugins/optimization-detective/settings.php | 2 +- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 8ae665e062..8348ae9ffe 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -608,6 +608,10 @@ export default async function detect( { // Wait for the page to be hidden. await new Promise( ( resolve ) => { if ( '' !== odPrimeUrlMetricsVerificationToken ) { + window.parent.postMessage( + 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', + '*' + ); resolve(); } @@ -711,40 +715,14 @@ export default async function detect( { 'prime_url_metrics_verification_token', odPrimeUrlMetricsVerificationToken ); + } - fetch( url, { - method: 'POST', - body: JSON.stringify( urlMetric ), - headers: { - 'Content-Type': 'application/json', - }, + navigator.sendBeacon( + url, + new Blob( [ JSON.stringify( urlMetric ) ], { + type: 'application/json', } ) - .then( ( response ) => { - if ( ! response.ok ) { - throw new Error( - `Failed to send URL Metric: ${ response.statusText }` - ); - } - window.parent.postMessage( - 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', - '*' - ); - } ) - .catch( ( err ) => { - window.parent.postMessage( - 'OD_PRIME_URL_METRICS_REQUEST_FAILURE', - '*' - ); - error( 'Failed to send URL Metric:', err ); - } ); - } else { - navigator.sendBeacon( - url, - new Blob( [ JSON.stringify( urlMetric ) ], { - type: 'application/json', - } ) - ); - } + ); // Clean up. breadcrumbedElementsMap.clear(); diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index e470eb24a5..d7767898fb 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -14,7 +14,6 @@ iframe.style.display = 'block'; iframe.style.position = 'absolute'; iframe.style.transform = 'translateX(-9999px)'; - iframe.style.visibility = 'hidden'; document.body.appendChild( iframe ); /** diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 4fbeba2225..af98574cc9 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -44,7 +44,7 @@ function od_render_optimization_detective_page(): void {
- +
From 82a16b59c8166f2e2a874ca0cd56b3eaf81441af Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 5 Feb 2025 15:49:44 +0530 Subject: [PATCH 20/62] Fix detect.js getting stuck on 'onLCP' due to IFRAME being offscreen or not visible --- .../prime-url-metrics-block-editor.js | 11 ++++++++--- plugins/optimization-detective/settings.php | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index d7767898fb..64f50a5a99 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -11,9 +11,14 @@ let breakpoints = []; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; - iframe.style.display = 'block'; - iframe.style.position = 'absolute'; - iframe.style.transform = 'translateX(-9999px)'; + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.right = '0'; + iframe.style.transform = 'scale(0.05)'; + iframe.style.transformOrigin = '0 0'; + iframe.style.pointerEvents = 'none'; + iframe.style.opacity = '0.000001'; + iframe.style.zIndex = '-9999'; document.body.appendChild( iframe ); /** diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index af98574cc9..8c63f1ae5d 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -44,7 +44,7 @@ function od_render_optimization_detective_page(): void {
- +
From a50f23e1f1ddb12d7dfc418b01026448cec7a37b Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 5 Feb 2025 16:23:24 +0530 Subject: [PATCH 21/62] Improve responsiveness of IFRAME in debug mode --- .../prime-url-metrics-block-editor.js | 5 ---- .../prime-url-metrics.js | 30 ++++++++++++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 64f50a5a99..d4a5511aa1 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -66,11 +66,6 @@ if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { cleanup(); resolve(); - } else if ( - event.data === 'OD_PRIME_URL_METRICS_REQUEST_FAILURE' - ) { - cleanup(); - reject( new Error( 'Failed to send metrics' ) ); } }; diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 87c79c72da..f1a17fddb0 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -21,6 +21,11 @@ 'iframe#od-prime-url-metrics-iframe' ); + /** @type {HTMLDivElement} */ + const iframeContainer = document.querySelector( + 'div#od-prime-url-metrics-iframe-container' + ); + let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; @@ -166,11 +171,6 @@ if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { cleanup(); resolve(); - } else if ( - event.data === 'OD_PRIME_URL_METRICS_REQUEST_FAILURE' - ) { - cleanup(); - reject( new Error( 'Failed to send metrics' ) ); } }; @@ -200,9 +200,23 @@ verificationToken; if ( isDebug ) { - iframe.style.position = 'unset'; - iframe.style.transform = 'scale(0.5) translate(-50%, -50%)'; - iframe.style.visibility = 'visible'; + function fitIframe() { + const containerWidth = iframeContainer.clientWidth; + if ( containerWidth <= 0 ) { + return; + } + + const nativeWidth = parseInt( iframe.width, 10 ) || 1; + const scale = containerWidth / nativeWidth; + + iframe.style.position = 'unset'; + iframe.style.transform = `scale(${ scale })`; + iframe.style.pointerEvents = 'auto'; + iframe.style.opacity = '1'; + iframe.style.zIndex = '9999'; + } + window.addEventListener( 'resize', fitIframe ); + fitIframe(); } } ); } From 163d24ea688225bb728ee9c7dc38ce469e787700 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 7 Feb 2025 22:42:36 +0530 Subject: [PATCH 22/62] Skip check of already submitted URL metrics --- plugins/optimization-detective/detect.js | 29 +++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index a724bb4119..082e072559 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -371,6 +371,18 @@ export default async function detect( { return; } + /** @type {HTMLIFrameElement|null} */ + const urlPrimeIframeElement = win.parent.document.querySelector( + 'iframe#od-prime-url-metrics-iframe' + ); + if ( + urlPrimeIframeElement && + urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken + ) { + odPrimeUrlMetricsVerificationToken = + urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken; + } + // Abort if the client already submitted a URL Metric for this URL and viewport group. const alreadySubmittedSessionStorageKey = await getAlreadySubmittedSessionStorageKey( @@ -378,7 +390,10 @@ export default async function detect( { currentUrl, urlMetricGroupStatus ); - if ( alreadySubmittedSessionStorageKey in sessionStorage ) { + if ( + '' === odPrimeUrlMetricsVerificationToken && + alreadySubmittedSessionStorageKey in sessionStorage + ) { const previousVisitTime = parseInt( sessionStorage.getItem( alreadySubmittedSessionStorageKey ), 10 @@ -435,18 +450,6 @@ export default async function detect( { } ); } - /** @type {HTMLIFrameElement|null} */ - const urlPrimeIframeElement = win.parent.document.querySelector( - 'iframe#od-prime-url-metrics-iframe' - ); - if ( - urlPrimeIframeElement && - urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken - ) { - odPrimeUrlMetricsVerificationToken = - urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken; - } - // TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition? // As an alternative to this, the od_print_detection_script() function can short-circuit if the // od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could From 2bc9f4925af6f9b2afdaa69fc245edc4040270ce Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 7 Feb 2025 23:15:43 +0530 Subject: [PATCH 23/62] Fix `IFRAME` positioning --- .../optimization-detective/prime-url-metrics-block-editor.js | 2 +- plugins/optimization-detective/settings.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index d4a5511aa1..53dec917d1 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -13,7 +13,7 @@ iframe.id = 'od-prime-url-metrics-iframe'; iframe.style.position = 'fixed'; iframe.style.top = '0'; - iframe.style.right = '0'; + iframe.style.left = '0'; iframe.style.transform = 'scale(0.05)'; iframe.style.transformOrigin = '0 0'; iframe.style.pointerEvents = 'none'; diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 8c63f1ae5d..23c02fdd53 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -44,7 +44,7 @@ function od_render_optimization_detective_page(): void {
- +
From d17dede31007401d73b47f2077b3cd95d6e36f79 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Sat, 8 Feb 2025 01:10:31 +0530 Subject: [PATCH 24/62] Add URL priming functionality for classic editor --- plugins/optimization-detective/helper.php | 50 +++++++- plugins/optimization-detective/hooks.php | 1 + .../prime-url-metrics-classic-editor.js | 121 ++++++++++++++++++ .../prime-url-metrics.js | 3 + 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 plugins/optimization-detective/prime-url-metrics-classic-editor.js diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 8a5c85574d..e7194e43df 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -124,7 +124,7 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string } /** - * Enqueues admin scripts. + * Enqueues scripts for the URL priming in the admin area. * * @since n.e.x.t * @access private @@ -141,10 +141,43 @@ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { true ); } + + if ( + 'post.php' === $hook_suffix && + function_exists( 'get_current_screen' ) && + isset( $_GET['od_classic_editor_post_update_nonce'] ) && + false !== wp_verify_nonce( $_GET['od_classic_editor_post_update_nonce'], 'od_classic_editor_post_update' ) && + isset( $_GET['post'] ) && + isset( $_GET['message'] ) && + 1 === (int) $_GET['message'] + ) { + $screen = get_current_screen(); + if ( $screen instanceof WP_Screen && ! $screen->is_block_editor() ) { + $permalink = get_permalink( (int) $_GET['post'] ); + + if ( false !== $permalink ) { + wp_enqueue_script( + 'od-prime-url-metrics-classic-editor', + plugins_url( od_get_asset_path( 'prime-url-metrics-classic-editor.js' ), __FILE__ ), + array( 'wp-i18n', 'wp-api-fetch' ), + OPTIMIZATION_DETECTIVE_VERSION, + true + ); + + wp_localize_script( + 'od-prime-url-metrics-classic-editor', + 'odPrimeURLMetricsClassicEditor', + array( + 'permalink' => $permalink, + ) + ); + } + } + } } /** - * Adds the Optimization Detective menu to the admin menu. + * Enqueues scripts for the URL priming in block editor. * * @since n.e.x.t * @access private @@ -159,6 +192,19 @@ function od_enqueue_block_editor_prime_url_metrics_scripts(): void { ); } +/** + * Adds a nonce to the post update redirect URL for the classic editor. + * + * @since n.e.x.t + * @access private + * + * @param string $location The redirect URL. + * @return string The updated redirect URL. + */ +function od_add_data_to_post_update_redirect_url_for_classic_editor( string $location ): string { + return add_query_arg( 'od_classic_editor_post_update_nonce', wp_create_nonce( 'od_classic_editor_post_update' ), $location ); +} + /** * Gets URLs for priming URL Metrics from sitemap in batches. * diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 8c8119d4dc..2d509ee2ea 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -27,4 +27,5 @@ add_action( 'admin_menu', 'od_add_optimization_detective_menu' ); add_action( 'admin_enqueue_scripts', 'od_enqueue_prime_url_metrics_scripts' ); add_action( 'enqueue_block_editor_assets', 'od_enqueue_block_editor_prime_url_metrics_scripts' ); +add_filter( 'redirect_post_location', 'od_add_data_to_post_update_redirect_url_for_classic_editor' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js new file mode 100644 index 0000000000..30cac9265c --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -0,0 +1,121 @@ +/** + * Helper script for the Priming URL Metrics in block editor. + */ + +/* global odPrimeURLMetricsClassicEditor */ +( function ( odPrimeURLMetricsClassicEditor ) { + // @ts-ignore + if ( 'undefined' === typeof odPrimeURLMetricsClassicEditor ) { + return; + } + const permalink = odPrimeURLMetricsClassicEditor.permalink; + // @ts-ignore + const { apiFetch } = wp; + + let isProcessing = false; + let breakpoints = []; + const iframe = document.createElement( 'iframe' ); + iframe.id = 'od-prime-url-metrics-iframe'; + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.left = '0'; + iframe.style.transform = 'scale(0.05)'; + iframe.style.transformOrigin = '0 0'; + iframe.style.pointerEvents = 'none'; + iframe.style.opacity = '0.000001'; + iframe.style.zIndex = '-9999'; + document.body.appendChild( iframe ); + + /** + * Primes the URL metrics for all breakpoints. + * @return {Promise} The promise that resolves to void. + */ + async function primeURL() { + isProcessing = true; + try { + if ( 0 === breakpoints.length ) { + breakpoints = await apiFetch( { + path: '/optimization-detective/v1/prime-urls-breakpoints', + method: 'GET', + } ); + } + + const verificationToken = await apiFetch( { + path: '/optimization-detective/v1/prime-urls-verification-token', + method: 'GET', + } ); + + for ( const breakpoint of breakpoints ) { + await processTask( { + url: permalink, + width: breakpoint.width, + height: breakpoint.height, + verificationToken, + } ); + } + isProcessing = false; + } catch ( error ) { + isProcessing = false; + } + } + + /** + * Loads the iframe and waits for the message. + * @param {{url: string, width: number, height: number, verificationToken: string}} task - The breakpoint to set for the iframe. + * @return {Promise} The promise that resolves to void. + */ + function processTask( task ) { + return new Promise( ( resolve, reject ) => { + const handleMessage = ( event ) => { + if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + cleanup(); + resolve(); + } + }; + + const cleanup = () => { + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + }; + + const timeoutId = setTimeout( () => { + cleanup(); + reject( new Error( 'Timeout waiting for message' ) ); + }, 30000 ); // 30-second timeout + + window.addEventListener( 'message', handleMessage ); + + iframe.onerror = () => { + cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + // Load the iframe + iframe.src = task.url; + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + iframe.dataset.odPrimeUrlMetricsVerificationToken = + task.verificationToken; + } ); + } + + /** + * Primes the URL metrics for all breakpoints + * when the document is ready. + */ + document.addEventListener( 'DOMContentLoaded', () => { + primeURL(); + } ); + + /** + * Prevent the user from leaving the page while processing. + */ + window.addEventListener( 'beforeunload', function ( event ) { + if ( isProcessing ) { + event.preventDefault(); + } + } ); + + // @ts-ignore +} )( odPrimeURLMetricsClassicEditor ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index f1a17fddb0..0f90fd4697 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -101,6 +101,9 @@ 'optimization-detective' ); controlButton.disabled = true; + iframe.src = 'about:blank'; + iframe.width = '0'; + iframe.height = '0'; } } } From 97b888fbb0dd8fed2ec363d6e9c1426241cac862 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 10 Feb 2025 20:02:20 +0530 Subject: [PATCH 25/62] Refactor to prime URL metrics only when the post is saved --- .../prime-url-metrics-block-editor.js | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 53dec917d1..da7a8e44dd 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -9,6 +9,8 @@ let isProcessing = false; let breakpoints = []; + let currentTasks = []; + const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; iframe.style.position = 'fixed'; @@ -26,7 +28,6 @@ * @return {Promise} The promise that resolves to void. */ async function primeURL() { - isProcessing = true; try { if ( 0 === breakpoints.length ) { breakpoints = await apiFetch( { @@ -41,15 +42,17 @@ method: 'GET', } ); - for ( const breakpoint of breakpoints ) { - await processTask( { - url: postURL, - width: breakpoint.width, - height: breakpoint.height, - verificationToken, - } ); + currentTasks = breakpoints.map( ( breakpoint ) => ( { + url: postURL, + width: breakpoint.width, + height: breakpoint.height, + verificationToken, + } ) ); + + if ( ! isProcessing && currentTasks.length > 0 ) { + isProcessing = true; + processTasks(); } - isProcessing = false; } catch ( error ) { isProcessing = false; } @@ -57,11 +60,11 @@ /** * Loads the iframe and waits for the message. - * @param {{url: string, width: number, height: number, verificationToken: string}} task - The breakpoint to set for the iframe. * @return {Promise} The promise that resolves to void. */ - function processTask( task ) { - return new Promise( ( resolve, reject ) => { + async function processTasks() { + const task = currentTasks.shift(); + await new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { cleanup(); @@ -94,18 +97,26 @@ iframe.dataset.odPrimeUrlMetricsVerificationToken = task.verificationToken; } ); + + if ( currentTasks.length > 0 ) { + processTasks(); + } else { + isProcessing = false; + } } - // Listen for post save/publish events + // Listen for post save/publish events. + let wasSaving = false; subscribe( () => { const isSaving = select( 'core/editor' ).isSavingPost(); const isAutosaving = select( 'core/editor' ).isAutosavingPost(); - const isPublished = - select( 'core/editor' ).getCurrentPost().status === 'publish'; - const isJustSaved = isSaving && ! isAutosaving && isPublished; - if ( isJustSaved ) { + + // Trigger when saving transitions from true to false (save completed). + if ( wasSaving && ! isSaving && ! isAutosaving ) { primeURL(); } + + wasSaving = isSaving; } ); /** From 931861b20322d0a54731e60f14638f1d73dee2de Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 10 Feb 2025 20:09:07 +0530 Subject: [PATCH 26/62] Move URL metrics success message to the end of the detection process --- plugins/optimization-detective/detect.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 6350ec8d71..5e075fbabd 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -685,10 +685,6 @@ export default async function detect( { // Wait for the page to be hidden. await new Promise( ( resolve ) => { if ( '' !== odPrimeUrlMetricsVerificationToken ) { - window.parent.postMessage( - 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', - '*' - ); resolve(); } @@ -850,6 +846,13 @@ export default async function detect( { } ) ); + if ( '' !== odPrimeUrlMetricsVerificationToken ) { + window.parent.postMessage( + 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', + '*' + ); + } + // Clean up. breadcrumbedElementsMap.clear(); } From 0a7c1f118cf3334d5e6676f4b89c5d646ce1ddcb Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 11 Feb 2025 17:47:29 +0530 Subject: [PATCH 27/62] Add instructions and important information section to settings page, Improve UI to show current batch and task --- .../prime-url-metrics-block-editor.js | 79 ++++++++++--------- .../prime-url-metrics.js | 28 +++++++ plugins/optimization-detective/settings.php | 24 +++++- 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index da7a8e44dd..7a8c0ce325 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -64,43 +64,50 @@ */ async function processTasks() { const task = currentTasks.shift(); - await new Promise( ( resolve, reject ) => { - const handleMessage = ( event ) => { - if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + + try { + await new Promise( ( resolve, reject ) => { + const handleMessage = ( event ) => { + if ( + event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' + ) { + cleanup(); + resolve(); + } + }; + + const cleanup = () => { + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + }; + + const timeoutId = setTimeout( () => { + cleanup(); + reject( new Error( 'Timeout waiting for message' ) ); + }, 30000 ); // 30-second timeout + + window.addEventListener( 'message', handleMessage ); + + iframe.onerror = () => { cleanup(); - resolve(); - } - }; - - const cleanup = () => { - window.removeEventListener( 'message', handleMessage ); - clearTimeout( timeoutId ); - iframe.onerror = null; - }; - - const timeoutId = setTimeout( () => { - cleanup(); - reject( new Error( 'Timeout waiting for message' ) ); - }, 30000 ); // 30-second timeout - - window.addEventListener( 'message', handleMessage ); - - iframe.onerror = () => { - cleanup(); - reject( new Error( 'Iframe failed to load' ) ); - }; - - // Load the iframe - iframe.src = task.url; - iframe.width = task.width.toString(); - iframe.height = task.height.toString(); - iframe.dataset.odPrimeUrlMetricsVerificationToken = - task.verificationToken; - } ); - - if ( currentTasks.length > 0 ) { - processTasks(); - } else { + reject( new Error( 'Iframe failed to load' ) ); + }; + + // Load the iframe + iframe.src = task.url; + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + iframe.dataset.odPrimeUrlMetricsVerificationToken = + task.verificationToken; + } ); + + if ( currentTasks.length > 0 ) { + processTasks(); + } else { + isProcessing = false; + } + } catch ( error ) { isProcessing = false; } } diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 0f90fd4697..04a014d365 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -26,6 +26,21 @@ 'div#od-prime-url-metrics-iframe-container' ); + /** @type {HTMLSpanElement} */ + const currentBatchElement = document.querySelector( + 'span#od-prime-url-metrics-current-batch' + ); + + /** @type {HTMLSpanElement} */ + const currentTaskElement = document.querySelector( + 'span#od-prime-url-metrics-current-task' + ); + + /** @type {HTMLSpanElement} */ + const totalTasksInBatchElement = document.querySelector( + 'span#od-prime-url-metrics-total-tasks-in-batch' + ); + let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; @@ -34,6 +49,7 @@ let currentBatch = null; let currentTasks = []; let currentTaskIndex = 0; + let currentBatchNumber = 0; /** * Handles the prime URL metrics control button click. @@ -60,6 +76,10 @@ break; } + currentBatchNumber++; + currentBatchElement.textContent = + currentBatchNumber.toString(); + // Initialize batch state verificationToken = currentBatch.verificationToken; isDebug = currentBatch.isDebug; @@ -67,6 +87,9 @@ currentTaskIndex = 0; progressBar.max = currentTasks.length; progressBar.value = 0; + totalTasksInBatchElement.textContent = + currentTasks.length.toString(); + currentTaskElement.textContent = '0'; } // Process tasks in current batch while ( @@ -76,6 +99,8 @@ await processTask( currentTasks[ currentTaskIndex ] ); currentTaskIndex++; progressBar.value = currentTaskIndex; + currentTaskElement.textContent = + currentTaskIndex.toString(); } if ( currentTaskIndex >= currentTasks.length ) { @@ -84,6 +109,8 @@ currentBatch = null; currentTasks = []; currentTaskIndex = 0; + totalTasksInBatchElement.textContent = '0'; + currentTaskElement.textContent = '0'; } } } catch ( error ) { @@ -104,6 +131,7 @@ iframe.src = 'about:blank'; iframe.width = '0'; iframe.height = '0'; + currentBatchElement.textContent = '0'; } } } diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 23c02fdd53..960eb4f43a 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -41,11 +41,31 @@ function od_render_optimization_detective_page(): void {

- +

+

+
    +
  1. +
  2. +
  3. +
  4. +
+

+
+

+

+

+
+
+ +
+
+ 0 + 0 / 0 +
-
+
From 969f6b5e7e9378124896f44afc6bb394112404af Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 11 Feb 2025 23:17:25 +0530 Subject: [PATCH 28/62] Pause URL metrics processing when tab visibility chages, Add abort functionality --- .../prime-url-metrics.js | 75 +++++++++++++++++-- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 04a014d365..646b9f6527 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -41,6 +41,18 @@ 'span#od-prime-url-metrics-total-tasks-in-batch' ); + if ( + ! controlButton || + ! progressBar || + ! iframe || + ! iframeContainer || + ! currentBatchElement || + ! currentTaskElement || + ! totalTasksInBatchElement + ) { + return; + } + let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; @@ -50,6 +62,8 @@ let currentTasks = []; let currentTaskIndex = 0; let currentBatchNumber = 0; + let isTabHidden = false; + let abortController = null; /** * Handles the prime URL metrics control button click. @@ -62,9 +76,16 @@ 'Resume', 'optimization-detective' ); + if ( abortController ) { + abortController.abort(); + abortController = null; + } } else { // Start/resume processing isProcessing = true; + if ( isTabHidden ) { + isTabHidden = false; + } controlButton.textContent = __( 'Pause', 'optimization-detective' ); try { @@ -96,7 +117,11 @@ isProcessing && currentTaskIndex < currentTasks.length ) { - await processTask( currentTasks[ currentTaskIndex ] ); + abortController = new AbortController(); + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); currentTaskIndex++; progressBar.value = currentTaskIndex; currentTaskElement.textContent = @@ -115,11 +140,13 @@ } } catch ( error ) { // TODO: Decide whether to skip the current task or stop processing. - isProcessing = false; - controlButton.textContent = __( - 'Click to retry', - 'optimization-detective' - ); + if ( ! isTabHidden && 'Task Aborted' !== error.message ) { + isProcessing = false; + controlButton.textContent = __( + 'Click to retry', + 'optimization-detective' + ); + } } finally { if ( ! isNextBatchAvailable ) { isProcessing = false; @@ -193,10 +220,11 @@ /** * Loads the iframe and waits for the message. - * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. + * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ - function processTask( task ) { + function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { @@ -205,10 +233,16 @@ } }; + const abortHandler = () => { + cleanup(); + reject( new Error( 'Task Aborted' ) ); + }; + const cleanup = () => { window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; + iframe.src = 'about:blank'; }; const timeoutId = setTimeout( () => { @@ -216,6 +250,12 @@ reject( new Error( 'Timeout waiting for message' ) ); }, 30000 ); // 30-second timeout + if ( signal.aborted ) { + abortHandler(); + return; + } + + signal.addEventListener( 'abort', abortHandler ); window.addEventListener( 'message', handleMessage ); iframe.onerror = () => { @@ -254,6 +294,25 @@ controlButton.addEventListener( 'click', handleControlButtonClick ); + /** + * Pause processing when the tab/window becomes hidden. + */ + document.addEventListener( 'visibilitychange', () => { + if ( document.visibilityState === 'hidden' && isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( abortController ) { + abortController.abort(); + abortController = null; + } + + controlButton.textContent = __( + 'Resume', + 'optimization-detective' + ); + } + } ); + /** * Prevent the user from leaving the page while processing. */ From c1e245dbbc628a4c32db6afca82dac8c02385c17 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 12 Feb 2025 13:54:21 +0530 Subject: [PATCH 29/62] Implement task processing with abort functionality and visibility handling --- .../prime-url-metrics-block-editor.js | 140 +++++++----- .../prime-url-metrics-classic-editor.js | 73 ++++-- .../prime-url-metrics.js | 211 +++++++++--------- 3 files changed, 246 insertions(+), 178 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 7a8c0ce325..2e363a4f50 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -8,8 +8,12 @@ const { apiFetch } = wp; let isProcessing = false; + let verificationToken = ''; let breakpoints = []; let currentTasks = []; + let currentTaskIndex = 0; + let isTabHidden = false; + let abortController = null; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -27,8 +31,9 @@ * Primes the URL metrics for all breakpoints. * @return {Promise} The promise that resolves to void. */ - async function primeURL() { + async function processTasks() { try { + isProcessing = true; if ( 0 === breakpoints.length ) { breakpoints = await apiFetch( { path: '/optimization-detective/v1/prime-urls-breakpoints', @@ -36,23 +41,27 @@ } ); } - const postURL = select( 'core/editor' ).getPermalink(); - const verificationToken = await apiFetch( { + const permalink = select( 'core/editor' ).getPermalink(); + verificationToken = await apiFetch( { path: '/optimization-detective/v1/prime-urls-verification-token', method: 'GET', } ); currentTasks = breakpoints.map( ( breakpoint ) => ( { - url: postURL, + url: permalink, width: breakpoint.width, height: breakpoint.height, - verificationToken, } ) ); - if ( ! isProcessing && currentTasks.length > 0 ) { - isProcessing = true; - processTasks(); + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + abortController = new AbortController(); + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + currentTaskIndex++; } + isProcessing = false; } catch ( error ) { isProcessing = false; } @@ -60,56 +69,56 @@ /** * Loads the iframe and waits for the message. + * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ - async function processTasks() { - const task = currentTasks.shift(); - - try { - await new Promise( ( resolve, reject ) => { - const handleMessage = ( event ) => { - if ( - event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' - ) { - cleanup(); - resolve(); - } - }; - - const cleanup = () => { - window.removeEventListener( 'message', handleMessage ); - clearTimeout( timeoutId ); - iframe.onerror = null; - }; - - const timeoutId = setTimeout( () => { + async function processTask( task, signal ) { + return new Promise( ( resolve, reject ) => { + const handleMessage = ( event ) => { + if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { cleanup(); - reject( new Error( 'Timeout waiting for message' ) ); - }, 30000 ); // 30-second timeout - - window.addEventListener( 'message', handleMessage ); - - iframe.onerror = () => { - cleanup(); - reject( new Error( 'Iframe failed to load' ) ); - }; - - // Load the iframe - iframe.src = task.url; - iframe.width = task.width.toString(); - iframe.height = task.height.toString(); - iframe.dataset.odPrimeUrlMetricsVerificationToken = - task.verificationToken; - } ); - - if ( currentTasks.length > 0 ) { - processTasks(); - } else { - isProcessing = false; + resolve(); + } + }; + + const abortHandler = () => { + cleanup(); + reject( new Error( 'Task Aborted' ) ); + }; + + const cleanup = () => { + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + iframe.src = 'about:blank'; + }; + + const timeoutId = setTimeout( () => { + cleanup(); + reject( new Error( 'Timeout waiting for message' ) ); + }, 30000 ); // 30-second timeout + + if ( signal.aborted ) { + abortHandler(); + return; } - } catch ( error ) { - isProcessing = false; - } + + signal.addEventListener( 'abort', abortHandler ); + window.addEventListener( 'message', handleMessage ); + + iframe.onerror = () => { + cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + // Load the iframe + iframe.src = task.url; + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + iframe.dataset.odPrimeUrlMetricsVerificationToken = + verificationToken; + } ); } // Listen for post save/publish events. @@ -120,12 +129,33 @@ // Trigger when saving transitions from true to false (save completed). if ( wasSaving && ! isSaving && ! isAutosaving ) { - primeURL(); + currentTaskIndex = 0; + processTasks(); } wasSaving = isSaving; } ); + /** + * Pause processing when the tab/window becomes hidden. + */ + document.addEventListener( 'visibilitychange', () => { + if ( 'hidden' === document.visibilityState && isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( abortController ) { + abortController.abort(); + abortController = null; + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + processTasks(); + } + } + } ); + /** * Prevent the user from leaving the page while processing. */ diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 30cac9265c..bbda017190 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -13,7 +13,13 @@ const { apiFetch } = wp; let isProcessing = false; + let verificationToken = ''; let breakpoints = []; + let currentTasks = []; + let currentTaskIndex = 0; + let isTabHidden = false; + let abortController = null; + const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; iframe.style.position = 'fixed'; @@ -30,9 +36,9 @@ * Primes the URL metrics for all breakpoints. * @return {Promise} The promise that resolves to void. */ - async function primeURL() { - isProcessing = true; + async function processTasks() { try { + isProcessing = true; if ( 0 === breakpoints.length ) { breakpoints = await apiFetch( { path: '/optimization-detective/v1/prime-urls-breakpoints', @@ -40,18 +46,24 @@ } ); } - const verificationToken = await apiFetch( { + verificationToken = await apiFetch( { path: '/optimization-detective/v1/prime-urls-verification-token', method: 'GET', } ); - for ( const breakpoint of breakpoints ) { - await processTask( { - url: permalink, - width: breakpoint.width, - height: breakpoint.height, - verificationToken, - } ); + currentTasks = breakpoints.map( ( breakpoint ) => ( { + url: permalink, + width: breakpoint.width, + height: breakpoint.height, + } ) ); + + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + abortController = new AbortController(); + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + currentTaskIndex++; } isProcessing = false; } catch ( error ) { @@ -61,10 +73,11 @@ /** * Loads the iframe and waits for the message. - * @param {{url: string, width: number, height: number, verificationToken: string}} task - The breakpoint to set for the iframe. + * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ - function processTask( task ) { + function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { @@ -73,10 +86,16 @@ } }; + const abortHandler = () => { + cleanup(); + reject( new Error( 'Task Aborted' ) ); + }; + const cleanup = () => { window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; + iframe.src = 'about:blank'; }; const timeoutId = setTimeout( () => { @@ -84,6 +103,12 @@ reject( new Error( 'Timeout waiting for message' ) ); }, 30000 ); // 30-second timeout + if ( signal.aborted ) { + abortHandler(); + return; + } + + signal.addEventListener( 'abort', abortHandler ); window.addEventListener( 'message', handleMessage ); iframe.onerror = () => { @@ -96,7 +121,7 @@ iframe.width = task.width.toString(); iframe.height = task.height.toString(); iframe.dataset.odPrimeUrlMetricsVerificationToken = - task.verificationToken; + verificationToken; } ); } @@ -105,7 +130,27 @@ * when the document is ready. */ document.addEventListener( 'DOMContentLoaded', () => { - primeURL(); + processTasks(); + } ); + + /** + * Pause processing when the tab/window becomes hidden. + */ + document.addEventListener( 'visibilitychange', () => { + if ( 'hidden' === document.visibilityState && isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( abortController ) { + abortController.abort(); + abortController = null; + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + processTasks(); + } + } } ); /** diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 646b9f6527..5576ff3ce2 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -66,9 +66,9 @@ let abortController = null; /** - * Handles the prime URL metrics control button click. + * Toggles the processing state. */ - async function handleControlButtonClick() { + function toggleProcessing() { if ( isProcessing ) { // Pause processing isProcessing = false; @@ -83,95 +83,94 @@ } else { // Start/resume processing isProcessing = true; - if ( isTabHidden ) { - isTabHidden = false; - } controlButton.textContent = __( 'Pause', 'optimization-detective' ); + processBatches(); + } + } - try { - while ( isProcessing ) { - if ( ! currentBatch ) { - currentBatch = await getBatch( cursor ); - if ( ! currentBatch.batch.length ) { - isNextBatchAvailable = false; - break; - } - - currentBatchNumber++; - currentBatchElement.textContent = - currentBatchNumber.toString(); - - // Initialize batch state - verificationToken = currentBatch.verificationToken; - isDebug = currentBatch.isDebug; - currentTasks = flattenBatchToTasks( currentBatch ); - currentTaskIndex = 0; - progressBar.max = currentTasks.length; - progressBar.value = 0; - totalTasksInBatchElement.textContent = - currentTasks.length.toString(); - currentTaskElement.textContent = '0'; - } - // Process tasks in current batch - while ( - isProcessing && - currentTaskIndex < currentTasks.length - ) { - abortController = new AbortController(); - await processTask( - currentTasks[ currentTaskIndex ], - abortController.signal - ); - currentTaskIndex++; - progressBar.value = currentTaskIndex; - currentTaskElement.textContent = - currentTaskIndex.toString(); + /** + * Processes batches of URLs. + */ + async function processBatches() { + try { + while ( isProcessing ) { + if ( ! currentBatch ) { + currentBatch = await getBatch( cursor ); + if ( ! currentBatch.batch.length ) { + isNextBatchAvailable = false; + break; } - if ( currentTaskIndex >= currentTasks.length ) { - // Complete current batch - cursor = currentBatch.cursor; - currentBatch = null; - currentTasks = []; - currentTaskIndex = 0; - totalTasksInBatchElement.textContent = '0'; - currentTaskElement.textContent = '0'; - } + currentBatchNumber++; + currentBatchElement.textContent = + currentBatchNumber.toString(); + + // Initialize batch state + verificationToken = currentBatch.verificationToken; + isDebug = currentBatch.isDebug; + currentTasks = flattenBatchToTasks( currentBatch ); + currentTaskIndex = 0; + progressBar.max = currentTasks.length; + progressBar.value = 0; + totalTasksInBatchElement.textContent = + currentTasks.length.toString(); + currentTaskElement.textContent = '0'; } - } catch ( error ) { - // TODO: Decide whether to skip the current task or stop processing. - if ( ! isTabHidden && 'Task Aborted' !== error.message ) { - isProcessing = false; - controlButton.textContent = __( - 'Click to retry', - 'optimization-detective' + + // Process tasks in current batch + while ( + isProcessing && + currentTaskIndex < currentTasks.length + ) { + abortController = new AbortController(); + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal ); + currentTaskIndex++; + progressBar.value = currentTaskIndex; + currentTaskElement.textContent = + currentTaskIndex.toString(); } - } finally { - if ( ! isNextBatchAvailable ) { - isProcessing = false; - controlButton.textContent = __( - 'Finished', - 'optimization-detective' - ); - controlButton.disabled = true; - iframe.src = 'about:blank'; - iframe.width = '0'; - iframe.height = '0'; - currentBatchElement.textContent = '0'; + + if ( currentTaskIndex >= currentTasks.length ) { + // Complete current batch + cursor = currentBatch.cursor; + currentBatch = null; + currentTasks = []; + currentTaskIndex = 0; + totalTasksInBatchElement.textContent = '0'; + currentTaskElement.textContent = '0'; } } + } catch ( error ) { + if ( ! isTabHidden && 'Task Aborted' !== error.message ) { + isProcessing = false; + controlButton.textContent = __( + 'Click to retry', + 'optimization-detective' + ); + } + } finally { + if ( ! isNextBatchAvailable ) { + isProcessing = false; + controlButton.textContent = __( + 'Finished', + 'optimization-detective' + ); + controlButton.disabled = true; + iframe.src = 'about:blank'; + iframe.width = '0'; + iframe.height = '0'; + currentBatchElement.textContent = '0'; + } } } /** * Flattens the batch to tasks. * @param {Object} batch - The batch to flatten. - * @return {Array<{ - * url: string, - * width: number, - * height: number - * }>} - The flattened tasks. + * @return {Array<{ url: string, width: number, height: number }>} - The flattened tasks. */ function flattenBatchToTasks( batch ) { const tasks = []; @@ -190,24 +189,7 @@ /** * Fetches the next batch of URLs. * @param {Object} lastCursor - The cursor to fetch the next batch. - * @return {Promise<{ - * batch: Array - * }>>, - * cursor: { - * provider_index: number, - * subtype_index: number, - * page_number: number, - * offset_within_page: number, - * batch_size: number - * }, - * verificationToken: string, - * isDebug: boolean - * }>} - The promise that resolves to the batch of URLs. + * @return {Promise} - The promise that resolves to the batch of URLs. */ async function getBatch( lastCursor ) { const response = await apiFetch( { @@ -220,8 +202,8 @@ /** * Loads the iframe and waits for the message. - * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. - * @param {AbortSignal} signal - The signal to abort the task. + * @param {{ url: string, width: number, height: number }} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ function processTask( task, signal ) { @@ -292,24 +274,35 @@ } ); } - controlButton.addEventListener( 'click', handleControlButtonClick ); + controlButton.addEventListener( 'click', toggleProcessing ); /** - * Pause processing when the tab/window becomes hidden. + * Pause processing when the tab/window becomes hidden, resume when visible. */ document.addEventListener( 'visibilitychange', () => { - if ( document.visibilityState === 'hidden' && isProcessing ) { - isProcessing = false; - isTabHidden = true; - if ( abortController ) { - abortController.abort(); - abortController = null; + if ( 'hidden' === document.visibilityState ) { + if ( isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( abortController ) { + abortController.abort(); + abortController = null; + } + controlButton.textContent = __( + 'Resume', + 'optimization-detective' + ); + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + controlButton.textContent = __( + 'Pause', + 'optimization-detective' + ); + processBatches(); } - - controlButton.textContent = __( - 'Resume', - 'optimization-detective' - ); } } ); From b7eeef749f9d77341217ba46e169fd444aea2397 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 13 Feb 2025 19:41:11 +0530 Subject: [PATCH 30/62] Add settings link to plugin action links --- plugins/optimization-detective/hooks.php | 1 + plugins/optimization-detective/load.php | 1 + .../prime-url-metrics.js | 14 ++++++++++ plugins/optimization-detective/settings.php | 28 ++++++++++++++++++- 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 2d509ee2ea..c40cbb9512 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -28,4 +28,5 @@ add_action( 'admin_enqueue_scripts', 'od_enqueue_prime_url_metrics_scripts' ); add_action( 'enqueue_block_editor_assets', 'od_enqueue_block_editor_prime_url_metrics_scripts' ); add_filter( 'redirect_post_location', 'od_add_data_to_post_update_redirect_url_for_classic_editor' ); +add_filter( 'plugin_action_links_' . OPTIMIZATION_DETECTIVE_MAIN_FILE, 'od_add_settings_action_link' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index e00cbcf8bf..97b02f1959 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -96,6 +96,7 @@ static function ( string $version ): void { } define( 'OPTIMIZATION_DETECTIVE_VERSION', $version ); + define( 'OPTIMIZATION_DETECTIVE_MAIN_FILE', plugin_basename( __FILE__ ) ); require_once __DIR__ . '/helper.php'; diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 5576ff3ce2..b925b7c04b 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -76,6 +76,7 @@ 'Resume', 'optimization-detective' ); + controlButton.classList.remove( 'updating-message' ); if ( abortController ) { abortController.abort(); abortController = null; @@ -84,6 +85,7 @@ // Start/resume processing isProcessing = true; controlButton.textContent = __( 'Pause', 'optimization-detective' ); + controlButton.classList.add( 'updating-message' ); processBatches(); } } @@ -95,7 +97,18 @@ try { while ( isProcessing ) { if ( ! currentBatch ) { + controlButton.textContent = __( + 'Getting next batch…', + 'optimization-detective' + ); + currentBatch = await getBatch( cursor ); + + controlButton.textContent = __( + 'Pause', + 'optimization-detective' + ); + if ( ! currentBatch.batch.length ) { isNextBatchAvailable = false; break; @@ -159,6 +172,7 @@ 'optimization-detective' ); controlButton.disabled = true; + controlButton.classList.remove( 'updating-message' ); iframe.src = 'about:blank'; iframe.width = '0'; iframe.height = '0'; diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 960eb4f43a..f1f5835174 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -71,4 +71,30 @@ function od_render_optimization_detective_page(): void { sprintf( + '%2$s', + esc_url( admin_url( 'tools.php?page=od-optimization-detective' ) ), + esc_html__( 'Settings', 'optimization-detective' ) + ), + ), + $links + ); +} +add_filter( 'plugin_action_links_' . OPTIMIZATION_DETECTIVE_MAIN_FILE, 'od_add_settings_action_link' ); From 42bfb981b2bca620a8e75828a4448d06917833dd Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 14 Feb 2025 16:58:32 +0530 Subject: [PATCH 31/62] Add minification support for classic editor JavaScript file --- webpack.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/webpack.config.js b/webpack.config.js index c0f590274e..bcc1947732 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -215,6 +215,10 @@ const optimizationDetective = ( env ) => { from: `${ destination }/prime-url-metrics-block-editor.js`, to: `${ destination }/prime-url-metrics-block-editor.min.js`, }, + { + from: `${ destination }/prime-url-metrics-classic-editor.js`, + to: `${ destination }/prime-url-metrics-classic-editor.min.js`, + }, ], } ), new WebpackBar( { From 7487ae89782b12c029734734d2a826543d887e7b Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 18 Feb 2025 13:06:12 +0530 Subject: [PATCH 32/62] Add function to disable admin-based URL priming feature when reached threshold --- plugins/optimization-detective/helper.php | 64 +++++++++++++++++++++ plugins/optimization-detective/settings.php | 6 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 03cdb729f2..c10c677d22 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -515,3 +515,67 @@ function od_filter_batch_urls_for_iframe_url_metrics_priming( array $urls ): arr return $filtered_batch; } + +/** + * Determines whether the admin-based URL priming feature should be displayed. + * + * Developers can force-enable the feature by filtering 'od_show_admin_url_priming_feature', or modify the + * threshold via 'od_admin_url_priming_threshold'. + * + * @since n.e.x.t + * + * @return bool True if the admin URL priming feature should be displayed, false otherwise. + */ +function od_show_admin_url_priming_feature(): bool { + /** + * Filters whether the admin URL priming feature should be shown in the admin dashboard. + * + * @since n.e.x.t + * + * @param bool $show_feature True if the feature should be shown, false otherwise. + */ + $force_show = apply_filters( 'od_show_admin_url_priming_feature', false ); + if ( $force_show ) { + return true; + } + + /** + * Filters the threshold for enabling the admin URL priming feature. + * + * @since n.e.x.t + * + * @param int $threshold The threshold count of frontend-visible URLs. + */ + $threshold = apply_filters( 'od_admin_url_priming_threshold', 1000 ); + $count = 0; + + // Get the sitemap server and its registry of providers. + $server = wp_sitemaps_get_server(); + $registry = $server->registry; + $providers = array_values( $registry->get_providers() ); + + foreach ( $providers as $provider ) { + // Each provider returns its object subtypes (e.g. 'post', 'page', etc.). + $subtypes = array_values( $provider->get_object_subtypes() ); + foreach ( $subtypes as $subtype ) { + $max_pages = $provider->get_max_num_pages( $subtype->name ); + for ( $page = 1; $page <= $max_pages; $page++ ) { + $url_list = $provider->get_url_list( $page, $subtype->name ); + if ( ! is_array( $url_list ) ) { + continue; + } + + $url_chunk = array_filter( array_column( $url_list, 'loc' ) ); + $count += count( $url_chunk ); + + // If we reach the threshold, disable the admin priming feature. + if ( $count >= $threshold ) { + return false; + } + } + } + } + + // If we reach here, the count is less than the threshold. Enable the admin priming feature. + return true; +} diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index f1f5835174..a53791220e 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -19,6 +19,10 @@ * @since n.e.x.t */ function od_add_optimization_detective_menu(): void { + if ( ! od_show_admin_url_priming_feature() ) { + return; + } + add_submenu_page( 'tools.php', __( 'Optimization Detective', 'optimization-detective' ), @@ -82,7 +86,7 @@ function od_render_optimization_detective_page(): void { * @return string[]|mixed The modified list of actions. */ function od_add_settings_action_link( $links ) { - if ( ! is_array( $links ) ) { + if ( ! is_array( $links ) || ! od_show_admin_url_priming_feature() ) { return $links; } From 17e24c4d3f54767f80d0ace78b4c75031f46063c Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 19 Mar 2025 00:32:12 +0530 Subject: [PATCH 33/62] Refactor REST API endpoints to use class --- plugins/optimization-detective/detection.php | 25 ++ plugins/optimization-detective/hooks.php | 1 + plugins/optimization-detective/load.php | 1 + ...s-od-rest-url-metrics-priming-endpoint.php | 222 ++++++++++++++++++ ...ass-od-rest-url-metrics-store-endpoint.php | 23 +- 5 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index dcf416824a..7d1a711f05 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -174,6 +174,31 @@ function od_register_rest_url_metric_store_endpoint(): void { ); } +/** + * Registers the REST API endpoint for priming URL Metrics. + * + * @since n.e.x.t + * @access private + */ +function od_register_rest_url_metric_priming_endpoint(): void { + $endpoint_controller = new OD_REST_URL_Metrics_Priming_Endpoint(); + register_rest_route( + OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, + $endpoint_controller::PRIME_URLS_ROUTE, + $endpoint_controller->get_registration_args_prime_urls() + ); + register_rest_route( + OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, + $endpoint_controller::PRIME_URLS_BREAKPOINTS_ROUTE, + $endpoint_controller->get_registration_args_prime_urls_breakpoints() + ); + register_rest_route( + OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, + $endpoint_controller::PRIME_URLS_VERIFICATION_TOKEN_ROUTE, + $endpoint_controller->get_registration_args_prime_urls_verification_token() + ); +} + /** * Triggers post update actions for page caches to invalidate their caches related to the supplied cache purge post ID. * diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 23d092a64a..f73d581d00 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -30,5 +30,6 @@ add_filter( 'redirect_post_location', 'od_add_data_to_post_update_redirect_url_for_classic_editor' ); add_filter( 'plugin_action_links_' . OPTIMIZATION_DETECTIVE_MAIN_FILE, 'od_add_settings_action_link' ); add_action( 'rest_api_init', 'od_register_rest_url_metric_store_endpoint' ); +add_action( 'rest_api_init', 'od_register_rest_url_metric_priming_endpoint' ); add_action( 'od_trigger_page_cache_invalidation', 'od_trigger_post_update_actions' ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 5c1d13399d..e3cb94fdc6 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -119,6 +119,7 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec require_once __DIR__ . '/storage/class-od-storage-lock.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/class-od-rest-url-metrics-store-endpoint.php'; + require_once __DIR__ . '/storage/class-od-rest-url-metrics-priming-endpoint.php'; require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php'; // Detection logic. diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php new file mode 100644 index 0000000000..0612c02d05 --- /dev/null +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php @@ -0,0 +1,222 @@ + 'POST', + 'callback' => array( $this, 'handle_generate_batch_urls_request' ), + 'permission_callback' => array( $this, 'priming_permissions_check' ), + ); + } + + /** + * Gets the arguments for registering the endpoint responsible for getting breakpoints for priming URL Metrics. + * + * @since n.e.x.t + * @access private + * + * @return array{ + * methods: string, + * callback: callable, + * permission_callback: callable + * } + */ + public function get_registration_args_prime_urls_breakpoints(): array { + return array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_generate_breakpoints_request' ), + 'permission_callback' => array( $this, 'priming_permissions_check' ), + ); + } + + /** + * Gets the arguments for registering the endpoint responsible for getting verification token for priming URLs Metrics. + * + * @since n.e.x.t + * @access private + * + * @return array{ + * methods: string, + * callback: callable, + * permission_callback: callable + * } + */ + public function get_registration_args_prime_urls_verification_token(): array { + return array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_verification_token_request' ), + 'permission_callback' => array( $this, 'priming_permissions_check' ), + ); + } + + /** + * Checks if a given request has access to prime URL metrics. + * + * @since n.e.x.t + * @access private + * + * @return true|WP_Error True if the request has permission, WP_Error object otherwise. + */ + public function priming_permissions_check() { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to access this resource.', 'optimization-detective' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Handles REST API request to generate batch URLs. + * + * @since n.e.x.t + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response Response. + */ + public function handle_generate_batch_urls_request( WP_REST_Request $request ): WP_REST_Response { + $cursor = $request->get_param( 'cursor' ); + + $default_cursor = array( + 'provider_index' => 0, + 'subtype_index' => 0, + 'page_number' => 1, + 'offset_within_page' => 0, + 'batch_size' => 10, + ); + + // Initialize cursor with default values. + $cursor = wp_parse_args( $cursor, $default_cursor ); + + if ( $default_cursor === $cursor ) { + $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); + if ( false !== $last_cursor ) { + $cursor = wp_parse_args( $last_cursor, $cursor ); + } + } else { + update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); + } + + $batch = array(); + $filtered_batch_urls = array(); + $prevent_infinite = 0; + while ( $prevent_infinite < 100 ) { + if ( count( $filtered_batch_urls ) > 0 ) { + break; + } + + $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); + $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + + if ( $cursor === $batch['cursor'] ) { + delete_option( 'od_prime_url_metrics_batch_cursor' ); + break; + } + $cursor = $batch['cursor']; + + ++$prevent_infinite; + } + + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + + if ( false === $verification_token ) { + $verification_token = wp_generate_uuid4(); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + + return new WP_REST_Response( + array( + 'batch' => $filtered_batch_urls, + 'cursor' => $batch['cursor'], + 'verificationToken' => $verification_token, + 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + ) + ); + } + + /** + * Handles REST API request to generate breakpoints for URL Metrics. + * + * @since n.e.x.t + * @access private + * + * @return WP_REST_Response Response. + */ + public function handle_generate_breakpoints_request(): WP_REST_Response { + return new WP_REST_Response( od_get_standard_breakpoints() ); + } + + /** + * Handles REST API request to get verification token for priming URLs Metrics. + * + * @since n.e.x.t + * @access private + * + * @return WP_REST_Response Response. + */ + public function handle_get_verification_token_request(): WP_REST_Response { + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + if ( false === $verification_token ) { + $verification_token = wp_generate_uuid4(); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + return new WP_REST_Response( $verification_token ); + } +} diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php index c007af6208..e8ac2a82b8 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php @@ -57,7 +57,7 @@ public function get_registration_args(): array { // The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter, // they are provided as input with the 'url' argument to create the HMAC by the server. $args = array( - 'slug' => array( + 'slug' => array( 'type' => 'string', 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), 'required' => true, @@ -65,7 +65,7 @@ public function get_registration_args(): array { 'minLength' => 32, 'maxLength' => 32, ), - 'current_etag' => array( + 'current_etag' => array( 'type' => 'string', 'description' => __( 'ETag for the current environment.', 'optimization-detective' ), 'required' => true, @@ -73,13 +73,13 @@ public function get_registration_args(): array { 'minLength' => 32, 'maxLength' => 32, ), - 'cache_purge_post_id' => array( + 'cache_purge_post_id' => array( 'type' => 'integer', 'description' => __( 'Cache purge post ID.', 'optimization-detective' ), 'required' => false, 'minimum' => 1, ), - 'hmac' => array( + 'hmac' => array( 'type' => 'string', 'description' => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ), 'required' => true, @@ -91,6 +91,11 @@ public function get_registration_args(): array { return true; }, ), + 'prime_url_metrics_verification_token' => array( + 'type' => 'string', + 'description' => __( 'Nonce for auto priming URLs.', 'optimization-detective' ), + 'required' => false, + ), ); return array( @@ -110,9 +115,17 @@ public function get_registration_args(): array { * @since 1.0.0 * @access private * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Request $request Request. * @return true|WP_Error True if the request has permission, WP_Error object otherwise. */ - public function store_permissions_check() { + public function store_permissions_check( WP_REST_Request $request ) { + // Authenticated requests when priming URL metrics through IFRAME. + $verification_token = $request->get_param( 'prime_url_metrics_verification_token' ); + if ( '' !== $verification_token && get_transient( 'od_prime_url_metrics_verification_token' ) === $verification_token ) { + return true; + } // Needs to be available to unauthenticated visitors. if ( OD_Storage_Lock::is_locked() ) { From 53cc46297aabed41d159d5b5906f53749bfd4455 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 31 Mar 2025 18:03:13 +0530 Subject: [PATCH 34/62] Refactor large URL metrics processing function into multiple smaller function for improved clarity --- .../prime-url-metrics-block-editor.js | 2 +- .../prime-url-metrics-classic-editor.js | 2 +- .../prime-url-metrics.js | 108 ++++++++++-------- 3 files changed, 61 insertions(+), 51 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 2e363a4f50..412aaffeac 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -76,7 +76,7 @@ async function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { - if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { cleanup(); resolve(); } diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index bbda017190..0c1a7143ed 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -80,7 +80,7 @@ function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { - if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { cleanup(); resolve(); } diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index b925b7c04b..81a5b52a62 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -1,5 +1,5 @@ /** - * Helper script for the Prime URL Metrics. + * Helper script for Priming URL Metrics through WordPress admin dashboard. */ ( function () { // @ts-ignore @@ -41,6 +41,7 @@ 'span#od-prime-url-metrics-total-tasks-in-batch' ); + // Ensure all required elements are present. if ( ! controlButton || ! progressBar || @@ -53,6 +54,7 @@ return; } + // Initialize state variables. let isProcessing = false; let isNextBatchAvailable = true; let cursor = {}; @@ -91,63 +93,22 @@ } /** - * Processes batches of URLs. + * Main processing controller function. */ async function processBatches() { try { while ( isProcessing ) { if ( ! currentBatch ) { - controlButton.textContent = __( - 'Getting next batch…', - 'optimization-detective' - ); - - currentBatch = await getBatch( cursor ); - - controlButton.textContent = __( - 'Pause', - 'optimization-detective' - ); - - if ( ! currentBatch.batch.length ) { - isNextBatchAvailable = false; + await prepareNextBatch(); + if ( ! isNextBatchAvailable ) { break; } - - currentBatchNumber++; - currentBatchElement.textContent = - currentBatchNumber.toString(); - - // Initialize batch state - verificationToken = currentBatch.verificationToken; - isDebug = currentBatch.isDebug; - currentTasks = flattenBatchToTasks( currentBatch ); - currentTaskIndex = 0; - progressBar.max = currentTasks.length; - progressBar.value = 0; - totalTasksInBatchElement.textContent = - currentTasks.length.toString(); - currentTaskElement.textContent = '0'; } - // Process tasks in current batch - while ( - isProcessing && - currentTaskIndex < currentTasks.length - ) { - abortController = new AbortController(); - await processTask( - currentTasks[ currentTaskIndex ], - abortController.signal - ); - currentTaskIndex++; - progressBar.value = currentTaskIndex; - currentTaskElement.textContent = - currentTaskIndex.toString(); - } + await processCurrentBatch(); + // Reset batch state if all tasks in the batch are processed. if ( currentTaskIndex >= currentTasks.length ) { - // Complete current batch cursor = currentBatch.cursor; currentBatch = null; currentTasks = []; @@ -181,6 +142,55 @@ } } + /** + * Prepares the next batch for processing. + */ + async function prepareNextBatch() { + controlButton.textContent = __( + 'Getting next batch…', + 'optimization-detective' + ); + currentBatch = await getBatch( cursor ); + + if ( ! currentBatch.batch.length ) { + isNextBatchAvailable = false; + return; + } + + currentBatchNumber++; + currentBatchElement.textContent = currentBatchNumber.toString(); + + // Initialize batch state. + verificationToken = currentBatch.verificationToken; + isDebug = currentBatch.isDebug; + currentTasks = flattenBatchToTasks( currentBatch ); + currentTaskIndex = 0; + + // Update UI for new batch. + progressBar.max = currentTasks.length; + progressBar.value = 0; + totalTasksInBatchElement.textContent = currentTasks.length.toString(); + currentTaskElement.textContent = '0'; + controlButton.textContent = __( 'Pause', 'optimization-detective' ); + } + + /** + * Processes tasks in the current batch. + */ + async function processCurrentBatch() { + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + abortController = new AbortController(); + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + + currentTaskIndex++; + progressBar.value = currentTaskIndex; + currentTaskElement.textContent = currentTaskIndex.toString(); + } + } + /** * Flattens the batch to tasks. * @param {Object} batch - The batch to flatten. @@ -223,7 +233,7 @@ function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { const handleMessage = ( event ) => { - if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { cleanup(); resolve(); } @@ -259,7 +269,7 @@ reject( new Error( 'Iframe failed to load' ) ); }; - // Load the iframe + // Load the iframe. iframe.src = task.url; iframe.width = task.width.toString(); iframe.height = task.height.toString(); From f9fd6049baca3e71d0f4da290d73fbd7bdb37b13 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 1 Apr 2025 23:54:53 +0530 Subject: [PATCH 35/62] Refactor to avoid creation of new `AbortController` for each task --- .../prime-url-metrics-block-editor.js | 29 +++++++++++++------ .../prime-url-metrics-classic-editor.js | 10 ++++--- .../prime-url-metrics.js | 16 ++++++---- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 412aaffeac..631805e96d 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -13,7 +13,8 @@ let currentTasks = []; let currentTaskIndex = 0; let isTabHidden = false; - let abortController = null; + let abortController = new AbortController(); + let processTasksPromise = null; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -54,7 +55,6 @@ } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { - abortController = new AbortController(); await processTask( currentTasks[ currentTaskIndex ], abortController.signal @@ -88,6 +88,7 @@ }; const cleanup = () => { + signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; @@ -123,17 +124,25 @@ // Listen for post save/publish events. let wasSaving = false; - subscribe( () => { + subscribe( async () => { const isSaving = select( 'core/editor' ).isSavingPost(); const isAutosaving = select( 'core/editor' ).isAutosavingPost(); // Trigger when saving transitions from true to false (save completed). if ( wasSaving && ! isSaving && ! isAutosaving ) { - currentTaskIndex = 0; - processTasks(); + wasSaving = false; + if ( processTasksPromise ) { + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + await processTasksPromise; + currentTaskIndex = 0; + abortController = new AbortController(); + } + processTasksPromise = processTasks(); + } else { + wasSaving = isSaving; } - - wasSaving = isSaving; } ); /** @@ -143,14 +152,16 @@ if ( 'hidden' === document.visibilityState && isProcessing ) { isProcessing = false; isTabHidden = true; - if ( abortController ) { + if ( ! abortController.signal.aborted ) { abortController.abort(); - abortController = null; } } else if ( 'visible' === document.visibilityState && isTabHidden ) { isTabHidden = false; if ( ! isProcessing ) { isProcessing = true; + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } processTasks(); } } diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 0c1a7143ed..1ddecedc34 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -18,7 +18,7 @@ let currentTasks = []; let currentTaskIndex = 0; let isTabHidden = false; - let abortController = null; + let abortController = new AbortController(); const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -58,7 +58,6 @@ } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { - abortController = new AbortController(); await processTask( currentTasks[ currentTaskIndex ], abortController.signal @@ -92,6 +91,7 @@ }; const cleanup = () => { + signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; @@ -140,14 +140,16 @@ if ( 'hidden' === document.visibilityState && isProcessing ) { isProcessing = false; isTabHidden = true; - if ( abortController ) { + if ( ! abortController.signal.aborted ) { abortController.abort(); - abortController = null; } } else if ( 'visible' === document.visibilityState && isTabHidden ) { isTabHidden = false; if ( ! isProcessing ) { isProcessing = true; + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } processTasks(); } } diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 81a5b52a62..c284296177 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -65,7 +65,7 @@ let currentTaskIndex = 0; let currentBatchNumber = 0; let isTabHidden = false; - let abortController = null; + let abortController = new AbortController(); /** * Toggles the processing state. @@ -79,15 +79,17 @@ 'optimization-detective' ); controlButton.classList.remove( 'updating-message' ); - if ( abortController ) { + if ( ! abortController.signal.aborted ) { abortController.abort(); - abortController = null; } } else { // Start/resume processing isProcessing = true; controlButton.textContent = __( 'Pause', 'optimization-detective' ); controlButton.classList.add( 'updating-message' ); + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } processBatches(); } } @@ -179,7 +181,6 @@ */ async function processCurrentBatch() { while ( isProcessing && currentTaskIndex < currentTasks.length ) { - abortController = new AbortController(); await processTask( currentTasks[ currentTaskIndex ], abortController.signal @@ -245,6 +246,7 @@ }; const cleanup = () => { + signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; @@ -308,9 +310,8 @@ if ( isProcessing ) { isProcessing = false; isTabHidden = true; - if ( abortController ) { + if ( ! abortController.signal.aborted ) { abortController.abort(); - abortController = null; } controlButton.textContent = __( 'Resume', @@ -325,6 +326,9 @@ 'Pause', 'optimization-detective' ); + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } processBatches(); } } From 8de014a89ff80feee63cdaef66167cc37437365b Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 3 Apr 2025 13:05:01 +0530 Subject: [PATCH 36/62] Add URL metrics priming CLI --- plugins/optimization-detective/detect.js | 11 +- plugins/optimization-detective/helper.php | 65 + plugins/optimization-detective/load.php | 3 + .../priming-cli/.gitignore | 78 + .../priming-cli/index.js | 152 ++ .../priming-cli/package-lock.json | 1459 +++++++++++++++++ .../priming-cli/package.json | 24 + .../priming-cli/tsconfig.json | 12 + .../priming-cli/types.d.ts | 7 + ...s-od-rest-url-metrics-priming-endpoint.php | 57 +- .../storage/class-od-wp-cli.php | 70 + 11 files changed, 1881 insertions(+), 57 deletions(-) create mode 100644 plugins/optimization-detective/priming-cli/.gitignore create mode 100644 plugins/optimization-detective/priming-cli/index.js create mode 100644 plugins/optimization-detective/priming-cli/package-lock.json create mode 100644 plugins/optimization-detective/priming-cli/package.json create mode 100644 plugins/optimization-detective/priming-cli/tsconfig.json create mode 100644 plugins/optimization-detective/priming-cli/types.d.ts create mode 100644 plugins/optimization-detective/storage/class-od-wp-cli.php diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index a7fc514626..5445b9045a 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -442,6 +442,12 @@ export default async function detect( { urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken; } + if ( location.search.includes( 'od-verification-token' ) ) { + odPrimeUrlMetricsVerificationToken = new URLSearchParams( + location.search + ).get( 'od-verification-token' ); + } + // Abort if the client already submitted a URL Metric for this URL and viewport group. const alreadySubmittedSessionStorageKey = await getAlreadySubmittedSessionStorageKey( @@ -502,7 +508,10 @@ export default async function detect( { } ); // Wait yet further until idle. - if ( typeof requestIdleCallback === 'function' ) { + if ( + '' === odPrimeUrlMetricsVerificationToken && + typeof requestIdleCallback === 'function' + ) { await new Promise( ( resolve ) => { requestIdleCallback( resolve ); } ); diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index b1f3effa7c..f2b8307dfe 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -563,3 +563,68 @@ function od_show_admin_url_priming_feature(): bool { // If we reach here, the count is less than the threshold. Enable the admin priming feature. return true; } + +/** + * Generates the final batch of URLs for priming URL Metrics. + * + * @since n.e.x.t + * @access private + * + * @param array $cursor Cursor to resume from. + * @return array Final batch of URLs to prime metrics for and the updated cursor. + */ +function od_generate_final_batch_urls( array $cursor ): array { + $default_cursor = array( + 'provider_index' => 0, + 'subtype_index' => 0, + 'page_number' => 1, + 'offset_within_page' => 0, + 'batch_size' => 10, + ); + + // Initialize cursor with default values. + $cursor = wp_parse_args( $cursor, $default_cursor ); + + if ( $default_cursor === $cursor ) { + $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); + if ( false !== $last_cursor ) { + $cursor = wp_parse_args( $last_cursor, $cursor ); + } + } else { + update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); + } + + $batch = array(); + $filtered_batch_urls = array(); + $prevent_infinite = 0; + while ( $prevent_infinite < 100 ) { + if ( count( $filtered_batch_urls ) > 0 ) { + break; + } + + $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); + $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + + if ( $cursor === $batch['cursor'] ) { + delete_option( 'od_prime_url_metrics_batch_cursor' ); + break; + } + $cursor = $batch['cursor']; + + ++$prevent_infinite; + } + + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + + if ( false === $verification_token ) { + $verification_token = wp_generate_uuid4(); + set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + + return array( + 'batch' => $filtered_batch_urls, + 'cursor' => $batch['cursor'], + 'verificationToken' => $verification_token, + 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + ); +} diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 5df3ad6394..46ac1653f8 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -139,5 +139,8 @@ static function ( string $version ): void { // Load the settings page. require_once __DIR__ . '/settings.php'; + + // Load WP-CLI commands. + require_once __DIR__ . '/storage/class-od-wp-cli.php'; } ); diff --git a/plugins/optimization-detective/priming-cli/.gitignore b/plugins/optimization-detective/priming-cli/.gitignore new file mode 100644 index 0000000000..cea30382b2 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/.gitignore @@ -0,0 +1,78 @@ +############ +## Generated files during testing +############ + +plugins/*/tests/**/actual.html + +############ +## IDEs +############ + +*.pydevproject +.project +.metadata +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.externalToolBuilders/ +*.launch +.cproject +.buildpath +nbproject/ +.vscode + +############ +## Build +############ + +build +.wp-env.override.json +*.min.js +*.min.css +*.asset.php + +############ +## Vendor +############ + +node_modules/ +vendor/ + +############ +## OSes +############ + +[Tt]humbs.db +[Dd]esktop.ini +*.DS_store +.DS_store? + +############ +## Config overrides for CS tools +############ +phpcs.xml +phpunit.xml +phpstan.neon + +############ +## Misc +############ + +tests/logs +tmp/ +*.tmp +*.bak +*.log +*.[Cc]ache +*.cpr +*.orig +*.php.in +.idea/ +.sass-cache/* +temp/ +._* +.Trashes +.svn \ No newline at end of file diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js new file mode 100644 index 0000000000..0693acc1e5 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/index.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { launch, Page } from 'puppeteer'; +import { program } from 'commander'; +import ora from 'ora'; +import { execSync } from 'child_process'; + +program + .name( 'od-prime' ) + .description( 'CLI tool to prime URL metrics for Optimization Detective' ) + .parse( process.argv ); + +const spinner = ora( 'Starting...' ).start(); +let browser; +let browserPage; + +process.on( 'SIGINT', async () => { + if ( browser ) { + await browser.close(); + } + spinner.fail( 'Process aborted.' ); + process.exit( 0 ); +} ); + +/** + * Fetches the next batch of URLs. + * @param {Object} lastCursor - The cursor to fetch the next batch. + * @return {Object} - The batch of URLs. + */ +function getBatch( lastCursor ) { + const batch = JSON.parse( + execSync( + `wp od get_url_batch --format=json --cursor='${ JSON.stringify( + lastCursor + ) }'` + ).toString() + ); + return batch[ 0 ]; +} + +/** + * Flattens the batch into individual tasks. + * @param {Object} batch - The batch to flatten. + * @return {Array<{ url: string, width: number, height: number }>} The list of tasks. + */ +function flattenBatchToTasks( batch ) { + const tasks = []; + for ( const urlObj of batch.batch ) { + for ( const breakpoint of urlObj.breakpoints ) { + tasks.push( { + url: urlObj.url, + width: breakpoint.width, + height: breakpoint.height, + } ); + } + } + return tasks; +} + +/** + * Processes a single task using Puppeteer. + * + * @param {Page} page - The Puppeteer page to use. + * @param {{ url: string, width: number, height: number }} task - The task parameters. + * @param {string} verificationToken - The verification token. + * @return {Promise} + */ +async function processTask( page, task, verificationToken ) { + // Before each navigation, reset the success flag. + await page.evaluate( () => { + window.success = false; + } ); + + const urlToLoad = new URL( task.url ); + urlToLoad.searchParams.append( 'od-verification-token', verificationToken ); + + await page.setViewport( { width: task.width, height: task.height } ); + + await page.goto( urlToLoad.toString(), { + waitUntil: 'load', + timeout: 30000, + } ); + + // Wait for the success flag to become true (with a 30-second timeout). + await page.waitForFunction( 'window.success === true', { timeout: 30000 } ); +} + +async function main() { + browser = await launch(); + browserPage = await browser.newPage(); + + await browserPage.evaluateOnNewDocument( () => { + window.success = false; + window.addEventListener( 'message', ( event ) => { + if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { + window.success = true; + } + } ); + } ); + + let isNextBatchAvailable = true; + let cursor = {}; + let currentBatchNumber = 0; + let verificationToken; + + // Process batches until no more are available. + while ( isNextBatchAvailable ) { + spinner.start( 'Fetching next batch...' ); + const currentBatch = await getBatch( cursor ); + // If no URLs remain in the batch, finish processing. + if ( ! currentBatch.batch || currentBatch.batch.length === 0 ) { + isNextBatchAvailable = false; + break; + } + verificationToken = currentBatch.verificationToken; + currentBatchNumber++; + + spinner.succeed( + `Batch ${ currentBatchNumber } fetched successfully.` + ); + + const currentTasks = flattenBatchToTasks( currentBatch ); + spinner.succeed( + `Batch ${ currentBatchNumber } processed successfully.` + ); + + // Process each task sequentially. + for ( let i = 0; i < currentTasks.length; i++ ) { + const task = currentTasks[ i ]; + spinner.start( + `Processing task ${ i + 1 }/${ currentTasks.length }` + ); + try { + await processTask( browserPage, task, verificationToken ); + spinner.succeed( + `Task ${ i + 1 }/${ + currentTasks.length + } completed successfully.` + ); + } catch ( error ) { + spinner.fail( + `Task ${ i + 1 }/${ currentTasks.length } failed.` + ); + } + } + cursor = currentBatch.cursor; + } + + spinner.succeed( 'All batches processed.' ); + await browser.close(); +} +main(); diff --git a/plugins/optimization-detective/priming-cli/package-lock.json b/plugins/optimization-detective/priming-cli/package-lock.json new file mode 100644 index 0000000000..1ab1ac8fd1 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/package-lock.json @@ -0,0 +1,1459 @@ +{ + "name": "optimization-detective-priming-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "optimization-detective-priming-cli", + "version": "1.0.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "ora": "^8.2.0", + "puppeteer": "^24.4.0" + }, + "bin": { + "od-prime": "index.js" + }, + "devDependencies": { + "typescript": "^5.8.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.9.0.tgz", + "integrity": "sha512-8+xM+cFydYET4X/5/3yZMHs7sjS6c9I6H5I3xJdb6cinzxWUT/I2QVw4avxCQ8QDndwdHkG/FiSZIrCjAbaKvQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.1", + "tar-fs": "^3.0.8", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz", + "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chromium-bidi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz", + "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1413902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1413902.tgz", + "integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", + "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.5.0.tgz", + "integrity": "sha512-3m0B48gj1A8cK01ma49WwjE8mg4i9UmnR2lP64rwBiLacJ2V20FpT67MgSUyzfz9BcHMQQweuF6Q854mnIYTqg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.9.0", + "chromium-bidi": "3.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1413902", + "puppeteer-core": "24.5.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.5.0.tgz", + "integrity": "sha512-vqibSk7xGOoqOlPUk3H+Iz02b4jCEd5QxaiuXclqyyBrJ6ZK22mXkg9HBSpyZePq6vKWh5ZAqUilSnbF2bv4Jg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.9.0", + "chromium-bidi": "3.0.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1413902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/tar-fs": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugins/optimization-detective/priming-cli/package.json b/plugins/optimization-detective/priming-cli/package.json new file mode 100644 index 0000000000..25e730f88e --- /dev/null +++ b/plugins/optimization-detective/priming-cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "optimization-detective-priming-cli", + "version": "1.0.0", + "description": "CLI tool to prime URL metrics for Optimization Detective", + "main": "index.js", + "type": "module", + "bin": { + "od-prime": "./index.js" + }, + "scripts": { + "start": "node index.js" + }, + "author": "WordPress Performance Team", + "license": "GPL-2.0-or-later", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "ora": "^8.2.0", + "puppeteer": "^24.4.0" + }, + "devDependencies": { + "typescript": "^5.8.2" + } +} \ No newline at end of file diff --git a/plugins/optimization-detective/priming-cli/tsconfig.json b/plugins/optimization-detective/priming-cli/tsconfig.json new file mode 100644 index 0000000000..804a4cadc9 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "checkJs": true, + "noEmit": true, + "allowJs": true, + "target": "es2020", + "moduleResolution": "node", + "module": "esnext", + "skipLibCheck": true + }, + "include": [ "index.js", "types.d.ts" ] +} diff --git a/plugins/optimization-detective/priming-cli/types.d.ts b/plugins/optimization-detective/priming-cli/types.d.ts new file mode 100644 index 0000000000..d1fa7ca486 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/types.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + interface Window { + success: boolean; + } +} diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php index 0612c02d05..aacbe22088 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php @@ -133,62 +133,7 @@ public function priming_permissions_check() { */ public function handle_generate_batch_urls_request( WP_REST_Request $request ): WP_REST_Response { $cursor = $request->get_param( 'cursor' ); - - $default_cursor = array( - 'provider_index' => 0, - 'subtype_index' => 0, - 'page_number' => 1, - 'offset_within_page' => 0, - 'batch_size' => 10, - ); - - // Initialize cursor with default values. - $cursor = wp_parse_args( $cursor, $default_cursor ); - - if ( $default_cursor === $cursor ) { - $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); - if ( false !== $last_cursor ) { - $cursor = wp_parse_args( $last_cursor, $cursor ); - } - } else { - update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); - } - - $batch = array(); - $filtered_batch_urls = array(); - $prevent_infinite = 0; - while ( $prevent_infinite < 100 ) { - if ( count( $filtered_batch_urls ) > 0 ) { - break; - } - - $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); - $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); - - if ( $cursor === $batch['cursor'] ) { - delete_option( 'od_prime_url_metrics_batch_cursor' ); - break; - } - $cursor = $batch['cursor']; - - ++$prevent_infinite; - } - - $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); - - if ( false === $verification_token ) { - $verification_token = wp_generate_uuid4(); - set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); - } - - return new WP_REST_Response( - array( - 'batch' => $filtered_batch_urls, - 'cursor' => $batch['cursor'], - 'verificationToken' => $verification_token, - 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, - ) - ); + return new WP_REST_Response( od_generate_final_batch_urls( $cursor ) ); } /** diff --git a/plugins/optimization-detective/storage/class-od-wp-cli.php b/plugins/optimization-detective/storage/class-od-wp-cli.php new file mode 100644 index 0000000000..62552c339c --- /dev/null +++ b/plugins/optimization-detective/storage/class-od-wp-cli.php @@ -0,0 +1,70 @@ +] + * : JSON encoded cursor to paginate through the URLs. + * --- + * default: [] + * --- + * + * [--format=] + * : Output format (table, json, csv, yaml) + * --- + * default: table + * --- + * + * ## EXAMPLES + * + * # Get a batch of URLs that need to be primed + * $ wp od get_url_batch --format=json + * + * # List 20 URL metrics in JSON format + * $ wp od get_url_batch --cursor='{"provider_index":0,"subtype_index":0,"page_number":1,"offset_within_page":0,"batch_size":10}' --format=json + * + * @param array $args Command arguments. + * @param array $assoc_args Command associated arguments. + */ + public function get_url_batch( array $args, array $assoc_args ): void { + $cursor = array(); + if ( isset( $assoc_args['cursor'] ) ) { + $cursor = json_decode( $assoc_args['cursor'], true ); + + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $cursor ) ) { + $cursor = array(); + } + } + $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; + + if ( function_exists( '\\WP_CLI\\Utils\\format_items' ) ) { + WP_CLI\Utils\format_items( $format, array( od_generate_final_batch_urls( $cursor ) ), array( 'batch', 'cursor', 'verificationToken', 'isDebug' ) ); + } + } +} + +// Register the WP-CLI command. +WP_CLI::add_command( 'od', new OD_WP_CLI() ); From ce69218ba1c6bdaef2276d217e0283167f61b783 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 3 Apr 2025 16:35:00 +0530 Subject: [PATCH 37/62] Improve task processing with `AbortController` and improve logging --- .../priming-cli/index.js | 125 +++++++++++++----- 1 file changed, 95 insertions(+), 30 deletions(-) diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 0693acc1e5..b9b25fe767 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -4,6 +4,7 @@ import { launch, Page } from 'puppeteer'; import { program } from 'commander'; import ora from 'ora'; import { execSync } from 'child_process'; +import chalk from 'chalk'; program .name( 'od-prime' ) @@ -13,13 +14,12 @@ program const spinner = ora( 'Starting...' ).start(); let browser; let browserPage; +const abortController = new AbortController(); +const { signal } = abortController; process.on( 'SIGINT', async () => { - if ( browser ) { - await browser.close(); - } - spinner.fail( 'Process aborted.' ); - process.exit( 0 ); + spinner.start( 'Aborting...' ); + abortController.abort(); } ); /** @@ -63,30 +63,55 @@ function flattenBatchToTasks( batch ) { * @param {Page} page - The Puppeteer page to use. * @param {{ url: string, width: number, height: number }} task - The task parameters. * @param {string} verificationToken - The verification token. + * @param {AbortSignal} abortSignal - The abort signal. * @return {Promise} */ -async function processTask( page, task, verificationToken ) { - // Before each navigation, reset the success flag. - await page.evaluate( () => { - window.success = false; - } ); +async function processTask( page, task, verificationToken, abortSignal ) { + return new Promise( async ( resolve, reject ) => { + function onAbort() { + reject( new Error( 'Task aborted.' ) ); + } + abortSignal.addEventListener( 'abort', onAbort ); + + try { + // Before each navigation, reset the success flag. + await page.evaluate( () => { + // @ts-ignore + window.success = false; + } ); - const urlToLoad = new URL( task.url ); - urlToLoad.searchParams.append( 'od-verification-token', verificationToken ); + const urlToLoad = new URL( task.url ); + urlToLoad.searchParams.append( + 'od-verification-token', + verificationToken + ); - await page.setViewport( { width: task.width, height: task.height } ); + // Set viewport dimensions. + await page.setViewport( { + width: task.width, + height: task.height, + } ); - await page.goto( urlToLoad.toString(), { - waitUntil: 'load', - timeout: 30000, - } ); + // Navigate to the URL. + await page.goto( urlToLoad.toString(), { + waitUntil: 'load', + } ); - // Wait for the success flag to become true (with a 30-second timeout). - await page.waitForFunction( 'window.success === true', { timeout: 30000 } ); + // Wait for the success flag to become true (with a 30-second timeout). + await page.waitForFunction( 'window.success === true', { + timeout: 30000, + } ); + } catch ( error ) { + reject( error ); + } finally { + abortSignal.removeEventListener( 'abort', onAbort ); + resolve(); + } + } ); } async function main() { - browser = await launch(); + browser = await launch( { headless: true } ); browserPage = await browser.newPage(); await browserPage.evaluateOnNewDocument( () => { @@ -105,6 +130,9 @@ async function main() { // Process batches until no more are available. while ( isNextBatchAvailable ) { + if ( signal.aborted ) { + break; + } spinner.start( 'Fetching next batch...' ); const currentBatch = await getBatch( cursor ); // If no URLs remain in the batch, finish processing. @@ -120,33 +148,70 @@ async function main() { ); const currentTasks = flattenBatchToTasks( currentBatch ); - spinner.succeed( - `Batch ${ currentBatchNumber } processed successfully.` - ); // Process each task sequentially. for ( let i = 0; i < currentTasks.length; i++ ) { + if ( signal.aborted ) { + break; + } const task = currentTasks[ i ]; + const taskStartTime = Date.now(); + spinner.start( - `Processing task ${ i + 1 }/${ currentTasks.length }` + `Processing task ${ chalk.green( + i + 1 + '/' + currentTasks.length + ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( + task.width + 'x' + task.height + ) }` ); try { - await processTask( browserPage, task, verificationToken ); + await processTask( + browserPage, + task, + verificationToken, + signal + ); + const taskEndTime = Date.now(); + const taskDuration = ( + ( taskEndTime - taskStartTime ) / + 1000 + ).toFixed( 2 ); spinner.succeed( - `Task ${ i + 1 }/${ - currentTasks.length - } completed successfully.` + `Task ${ chalk.green( + i + 1 + '/' + currentTasks.length + ) } completed successfully in ${ chalk.blue( + taskDuration + ) } seconds for ${ chalk.blue( + task.url + ) } at ${ chalk.blue( task.width + 'x' + task.height ) }.` ); } catch ( error ) { + const taskEndTime = Date.now(); + const taskDuration = ( + ( taskEndTime - taskStartTime ) / + 1000 + ).toFixed( 2 ); spinner.fail( - `Task ${ i + 1 }/${ currentTasks.length } failed.` + `Task ${ chalk.green( + i + 1 + '/' + currentTasks.length + ) } failed after ${ chalk.blue( + taskDuration + ) } seconds for ${ chalk.blue( + task.url + ) } at ${ chalk.blue( task.width + 'x' + task.height ) }. + Error: ${ chalk.red( error.message ) }` ); } } cursor = currentBatch.cursor; } - spinner.succeed( 'All batches processed.' ); + if ( signal.aborted ) { + spinner.fail( 'Aborted.' ); + } else { + spinner.succeed( 'All batches processed.' ); + } await browser.close(); + process.exit( 0 ); } main(); From 7908b895dd2170e988e260f2626a4df34b433db9 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 3 Apr 2025 22:09:28 +0530 Subject: [PATCH 38/62] Refactor to use custom event for communication and improve spinner messaging --- plugins/optimization-detective/detect.js | 4 + .../priming-cli/index.js | 89 ++++++------------- 2 files changed, 31 insertions(+), 62 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 5445b9045a..2881b50b21 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -922,5 +922,9 @@ export default async function detect( { 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', '*' ); + + document.dispatchEvent( + new CustomEvent( 'odPrimeUrlMetricsRequestSuccess' ) + ); } } diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index b9b25fe767..b7adbbd733 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -74,12 +74,6 @@ async function processTask( page, task, verificationToken, abortSignal ) { abortSignal.addEventListener( 'abort', onAbort ); try { - // Before each navigation, reset the success flag. - await page.evaluate( () => { - // @ts-ignore - window.success = false; - } ); - const urlToLoad = new URL( task.url ); urlToLoad.searchParams.append( 'od-verification-token', @@ -97,9 +91,14 @@ async function processTask( page, task, verificationToken, abortSignal ) { waitUntil: 'load', } ); - // Wait for the success flag to become true (with a 30-second timeout). - await page.waitForFunction( 'window.success === true', { - timeout: 30000, + await page.evaluate( () => { + return new Promise( ( requestSuccessResolve ) => { + document.addEventListener( + 'odPrimeUrlMetricsRequestSuccess', + requestSuccessResolve, + { once: true } + ); + } ); } ); } catch ( error ) { reject( error ); @@ -110,19 +109,14 @@ async function processTask( page, task, verificationToken, abortSignal ) { } ); } -async function main() { +/** + * Init function to process all batches. + * @return {Promise} + */ +async function init() { browser = await launch( { headless: true } ); browserPage = await browser.newPage(); - await browserPage.evaluateOnNewDocument( () => { - window.success = false; - window.addEventListener( 'message', ( event ) => { - if ( event.data === 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) { - window.success = true; - } - } ); - } ); - let isNextBatchAvailable = true; let cursor = {}; let currentBatchNumber = 0; @@ -133,7 +127,7 @@ async function main() { if ( signal.aborted ) { break; } - spinner.start( 'Fetching next batch...' ); + spinner.text = 'Fetching next batch'; const currentBatch = await getBatch( cursor ); // If no URLs remain in the batch, finish processing. if ( ! currentBatch.batch || currentBatch.batch.length === 0 ) { @@ -143,9 +137,7 @@ async function main() { verificationToken = currentBatch.verificationToken; currentBatchNumber++; - spinner.succeed( - `Batch ${ currentBatchNumber } fetched successfully.` - ); + spinner.text = `Batch ${ currentBatchNumber } fetched successfully.`; const currentTasks = flattenBatchToTasks( currentBatch ); @@ -155,15 +147,12 @@ async function main() { break; } const task = currentTasks[ i ]; - const taskStartTime = Date.now(); - - spinner.start( - `Processing task ${ chalk.green( - i + 1 + '/' + currentTasks.length - ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( - task.width + 'x' + task.height - ) }` - ); + + spinner.text = `Processing task ${ chalk.green( + i + 1 + '/' + currentTasks.length + ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( + task.width + 'x' + task.height + ) }`; try { await processTask( browserPage, @@ -171,36 +160,10 @@ async function main() { verificationToken, signal ); - const taskEndTime = Date.now(); - const taskDuration = ( - ( taskEndTime - taskStartTime ) / - 1000 - ).toFixed( 2 ); - spinner.succeed( - `Task ${ chalk.green( - i + 1 + '/' + currentTasks.length - ) } completed successfully in ${ chalk.blue( - taskDuration - ) } seconds for ${ chalk.blue( - task.url - ) } at ${ chalk.blue( task.width + 'x' + task.height ) }.` - ); } catch ( error ) { - const taskEndTime = Date.now(); - const taskDuration = ( - ( taskEndTime - taskStartTime ) / - 1000 - ).toFixed( 2 ); - spinner.fail( - `Task ${ chalk.green( - i + 1 + '/' + currentTasks.length - ) } failed after ${ chalk.blue( - taskDuration - ) } seconds for ${ chalk.blue( - task.url - ) } at ${ chalk.blue( task.width + 'x' + task.height ) }. - Error: ${ chalk.red( error.message ) }` - ); + spinner.text = `Error processing task ${ i + 1 }. Error: ${ + error.message + }`; } } cursor = currentBatch.cursor; @@ -214,4 +177,6 @@ async function main() { await browser.close(); process.exit( 0 ); } -main(); + +// Start the process. +init(); From 3df2f3613b166ec7e8f0ae0fa87738791fed6be1 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 3 Apr 2025 23:26:33 +0530 Subject: [PATCH 39/62] Refactor to use global varible to pass verification token to page --- plugins/optimization-detective/detect.js | 46 +++++++++++-------- .../priming-cli/index.js | 43 ++++++++++------- .../priming-cli/types.d.ts | 2 +- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 2881b50b21..246c94ece2 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -430,22 +430,32 @@ export default async function detect( { return; } - /** @type {HTMLIFrameElement|null} */ - const urlPrimeIframeElement = win.parent.document.querySelector( - 'iframe#od-prime-url-metrics-iframe' - ); - if ( - urlPrimeIframeElement && - urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken - ) { - odPrimeUrlMetricsVerificationToken = - urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken; + try { + if ( + win.parent && + win.location.origin === win.parent.location.origin + ) { + /** @type {HTMLIFrameElement|null} */ + const urlPrimeIframeElement = win.parent.document.querySelector( + 'iframe#od-prime-url-metrics-iframe' + ); + if ( + urlPrimeIframeElement && + urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken + ) { + odPrimeUrlMetricsVerificationToken = + urlPrimeIframeElement.dataset + .odPrimeUrlMetricsVerificationToken; + } + } + } catch ( e ) { + // Ignoring error caused possibly due to cross-origin iframe access. } - if ( location.search.includes( 'od-verification-token' ) ) { - odPrimeUrlMetricsVerificationToken = new URLSearchParams( - location.search - ).get( 'od-verification-token' ); + // Only available when page is loaded by Puppeteer script. + if ( win.__odPrimeUrlMetricsVerificationToken ) { + odPrimeUrlMetricsVerificationToken = + win.__odPrimeUrlMetricsVerificationToken; } // Abort if the client already submitted a URL Metric for this URL and viewport group. @@ -508,10 +518,7 @@ export default async function detect( { } ); // Wait yet further until idle. - if ( - '' === odPrimeUrlMetricsVerificationToken && - typeof requestIdleCallback === 'function' - ) { + if ( typeof requestIdleCallback === 'function' ) { await new Promise( ( resolve ) => { requestIdleCallback( resolve ); } ); @@ -922,9 +929,8 @@ export default async function detect( { 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', '*' ); - document.dispatchEvent( - new CustomEvent( 'odPrimeUrlMetricsRequestSuccess' ) + new CustomEvent( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) ); } } diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index b7adbbd733..c1b60d4c1c 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -74,31 +74,42 @@ async function processTask( page, task, verificationToken, abortSignal ) { abortSignal.addEventListener( 'abort', onAbort ); try { - const urlToLoad = new URL( task.url ); - urlToLoad.searchParams.append( - 'od-verification-token', - verificationToken - ); - - // Set viewport dimensions. await page.setViewport( { width: task.width, height: task.height, } ); - // Navigate to the URL. - await page.goto( urlToLoad.toString(), { + await page.evaluateOnNewDocument( ( token ) => { + window.__odPrimeUrlMetricsVerificationToken = token; + }, verificationToken ); + + await page.goto( task.url, { waitUntil: 'load', + timeout: 30000, } ); await page.evaluate( () => { - return new Promise( ( requestSuccessResolve ) => { - document.addEventListener( - 'odPrimeUrlMetricsRequestSuccess', - requestSuccessResolve, - { once: true } - ); - } ); + return new Promise( + ( requestSuccessResolve, requestSuccessReject ) => { + // Set timeout for 30 seconds. + const timeoutId = setTimeout( () => { + requestSuccessReject( + new Error( + 'Timed out waiting for event "OD_PRIME_URL_METRICS_REQUEST_SUCCESS".' + ) + ); + }, 30000 ); + + document.addEventListener( + 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', + async () => { + clearTimeout( timeoutId ); + requestSuccessResolve(); + }, + { once: true } + ); + } + ); } ); } catch ( error ) { reject( error ); diff --git a/plugins/optimization-detective/priming-cli/types.d.ts b/plugins/optimization-detective/priming-cli/types.d.ts index d1fa7ca486..41d9418e92 100644 --- a/plugins/optimization-detective/priming-cli/types.d.ts +++ b/plugins/optimization-detective/priming-cli/types.d.ts @@ -2,6 +2,6 @@ export {}; declare global { interface Window { - success: boolean; + __odPrimeUrlMetricsVerificationToken: string; } } From 14f8be3332787b260d06bc30512f1c398ee999de Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 4 Apr 2025 22:40:17 +0530 Subject: [PATCH 40/62] Add environment checks --- .../priming-cli/index.js | 111 +++++++++++++++--- .../priming-cli/package-lock.json | 4 +- .../priming-cli/package.json | 2 +- 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index c1b60d4c1c..a0d3dc2a51 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -2,8 +2,8 @@ import { launch, Page } from 'puppeteer'; import { program } from 'commander'; -import ora from 'ora'; import { execSync } from 'child_process'; +import ora from 'ora'; import chalk from 'chalk'; program @@ -11,31 +11,94 @@ program .description( 'CLI tool to prime URL metrics for Optimization Detective' ) .parse( process.argv ); +/** + * Instance of ora spinner for displaying status messages. + * @type {import('ora').Ora} + */ const spinner = ora( 'Starting...' ).start(); -let browser; -let browserPage; + +/** + * Instance of AbortController to handle aborting tasks. + * @type {AbortController} + */ const abortController = new AbortController(); -const { signal } = abortController; +/** + * Abort signal to be used to detect abort events. + * @type {AbortSignal} + */ +const signal = abortController.signal; + +// Listen for the SIGINT signal (Ctrl+C) to abort the process. process.on( 'SIGINT', async () => { spinner.start( 'Aborting...' ); abortController.abort(); } ); +/** + * Checks the environment for required tools and plugins. + * @return {boolean} - True if all checks passed, false otherwise. + */ +function checkEnvironment() { + const checks = [ + { + name: 'WP CLI Availability', + command: 'wp --info', + errorMessage: + 'WP CLI is not installed. Please install WP CLI and try again.', + }, + { + name: 'WordPress Availability', + command: 'wp core is-installed', + errorMessage: + 'WordPress is not installed or not accessible in this context.', + }, + { + name: 'Optimization Detective WP_CLI command', + command: 'wp help od get_url_batch', + errorMessage: + 'Optimization Detective plugin is not installed or activated. Please install and activate the plugin.', + }, + ]; + + for ( const check of checks ) { + try { + execSync( check.command, { stdio: 'ignore' } ); + } catch ( error ) { + spinner.fail( + chalk.red( + `${ check.name } check failed: ${ check.errorMessage }` + ) + ); + return false; + } + } + return true; +} + /** * Fetches the next batch of URLs. * @param {Object} lastCursor - The cursor to fetch the next batch. - * @return {Object} - The batch of URLs. + * @return {?Object} - The batch of URLs. */ function getBatch( lastCursor ) { - const batch = JSON.parse( - execSync( + try { + const batchOutput = execSync( `wp od get_url_batch --format=json --cursor='${ JSON.stringify( lastCursor ) }'` - ).toString() - ); - return batch[ 0 ]; + ).toString(); + const parsedBatch = JSON.parse( batchOutput ); + + if ( ! parsedBatch || parsedBatch.length === 0 ) { + throw new Error( 'Invalid batch data received.' ); + } + return JSON.parse( batchOutput )[ 0 ]; + } catch ( error ) { + spinner.fail( 'Error occurred while fetching batch: ' + error.message ); + abortController.abort(); + return null; + } } /** @@ -68,6 +131,9 @@ function flattenBatchToTasks( batch ) { */ async function processTask( page, task, verificationToken, abortSignal ) { return new Promise( async ( resolve, reject ) => { + /** + * Handles the abort event. + */ function onAbort() { reject( new Error( 'Task aborted.' ) ); } @@ -125,9 +191,8 @@ async function processTask( page, task, verificationToken, abortSignal ) { * @return {Promise} */ async function init() { - browser = await launch( { headless: true } ); - browserPage = await browser.newPage(); - + const browser = await launch( { headless: true } ); + const browserPage = await browser.newPage(); let isNextBatchAvailable = true; let cursor = {}; let currentBatchNumber = 0; @@ -140,8 +205,13 @@ async function init() { } spinner.text = 'Fetching next batch'; const currentBatch = await getBatch( cursor ); + // If no URLs remain in the batch, finish processing. - if ( ! currentBatch.batch || currentBatch.batch.length === 0 ) { + if ( + null === currentBatch || + ! currentBatch.batch || + currentBatch.batch.length === 0 + ) { isNextBatchAvailable = false; break; } @@ -180,14 +250,17 @@ async function init() { cursor = currentBatch.cursor; } + // Close the browser. + await browser.close(); + if ( signal.aborted ) { - spinner.fail( 'Aborted.' ); + spinner.fail( chalk.red( 'Aborted.' ) ); } else { - spinner.succeed( 'All batches processed.' ); + spinner.succeed( chalk.green( 'All tasks completed.' ) ); } - await browser.close(); - process.exit( 0 ); } // Start the process. -init(); +if ( checkEnvironment() ) { + init(); +} diff --git a/plugins/optimization-detective/priming-cli/package-lock.json b/plugins/optimization-detective/priming-cli/package-lock.json index 1ab1ac8fd1..b3f717ddd1 100644 --- a/plugins/optimization-detective/priming-cli/package-lock.json +++ b/plugins/optimization-detective/priming-cli/package-lock.json @@ -1,11 +1,11 @@ { - "name": "optimization-detective-priming-cli", + "name": "@wordpress/performance-od-url-metric-collector", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "optimization-detective-priming-cli", + "name": "@wordpress/performance-od-url-metric-collector", "version": "1.0.0", "license": "GPL-2.0-or-later", "dependencies": { diff --git a/plugins/optimization-detective/priming-cli/package.json b/plugins/optimization-detective/priming-cli/package.json index 25e730f88e..f7c76d049b 100644 --- a/plugins/optimization-detective/priming-cli/package.json +++ b/plugins/optimization-detective/priming-cli/package.json @@ -1,5 +1,5 @@ { - "name": "optimization-detective-priming-cli", + "name": "@wordpress/performance-od-url-metric-collector", "version": "1.0.0", "description": "CLI tool to prime URL metrics for Optimization Detective", "main": "index.js", From a2ebf1784e7ede75c39f3aed34ba788cbdf916f6 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 11 Apr 2025 22:30:28 +0530 Subject: [PATCH 41/62] Update js-lint workflow to install npm packages for optimization-detective priming CLI --- .github/workflows/js-lint.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/js-lint.yml b/.github/workflows/js-lint.yml index 365d32b9d6..b840bc8041 100644 --- a/.github/workflows/js-lint.yml +++ b/.github/workflows/js-lint.yml @@ -45,7 +45,9 @@ jobs: node-version-file: '.nvmrc' cache: npm - name: npm install - run: npm ci + run: | + npm ci + npm ci --prefix plugins/optimization-detective/priming-cli - name: JS Lint run: npm run lint-js - name: TypeScript compile From 3a795e94c86b65586071c01d0344139fec1f286c Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Sat, 12 Apr 2025 01:07:09 +0530 Subject: [PATCH 42/62] Improve communication of response of URL Metric sending request Co-authored-by: Weston Ruter --- plugins/optimization-detective/detect.js | 73 +++++++++++++++---- .../prime-url-metrics-block-editor.js | 59 +++++++++++++-- .../prime-url-metrics-classic-editor.js | 59 +++++++++++++-- .../prime-url-metrics.js | 68 ++++++++++++++--- .../priming-cli/index.js | 24 +++++- 5 files changed, 238 insertions(+), 45 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 0285ca7070..bd5e29cdb4 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -65,7 +65,13 @@ const storageLockTimeSessionKey = 'odStorageLockTime'; */ const compressionDebounceWaitDuration = 1000; -let odPrimeUrlMetricsVerificationToken = ''; +/** + * Verification token for skipping the storage lock check while priming URL Metrics. + * + * @see {detect} + * @type {?string} + */ +let odPrimeUrlMetricsVerificationToken = null; /** * Checks whether storage is locked. @@ -75,7 +81,7 @@ let odPrimeUrlMetricsVerificationToken = ''; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { - if ( '' !== odPrimeUrlMetricsVerificationToken ) { + if ( odPrimeUrlMetricsVerificationToken ) { return false; } @@ -509,6 +515,38 @@ function debounceCompressUrlMetric() { }, compressionDebounceWaitDuration ); } +/** + * Notifies about the URL Metric request status. + * + * @param {Object} status - The status details. + * @param {boolean} status.success - Indicates if the request succeeded. + * @param {string} [status.error] - An error message if the request failed. + * @param {Object} [options] - Options for where to dispatch the message. + * @param {boolean} [options.toParent=true] - Whether to send the message to the parent window. + * @param {boolean} [options.toLocal=true] - Whether to dispatch a custom event locally. + */ +function notifyStatus( status, options = { toParent: true, toLocal: true } ) { + const message = { + type: 'OD_PRIME_URL_METRICS_REQUEST_STATUS', + success: status.success, + ...( status.error && { error: status.error } ), + }; + + // This will be used when URL metrics are primed using a IFRAME. + if ( options.toParent && window.parent && window.parent !== window ) { + window.parent.postMessage( message, '*' ); + } + + // This will be used when URL metrics are primed using Puppeteer script. + if ( options.toLocal ) { + document.dispatchEvent( + new CustomEvent( message.type, { + detail: { ...status }, + } ) + ); + } +} + /** * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData * @typedef {{groups: Array<{url_metrics: Array}>}} CollectionDebugData @@ -638,7 +676,7 @@ export default async function detect( { logger ); if ( - '' === odPrimeUrlMetricsVerificationToken && + ! odPrimeUrlMetricsVerificationToken && null !== alreadySubmittedSessionStorageKey && alreadySubmittedSessionStorageKey in sessionStorage ) { @@ -957,7 +995,7 @@ export default async function detect( { // Wait for the page to be hidden. await new Promise( ( resolve ) => { - if ( '' !== odPrimeUrlMetricsVerificationToken ) { + if ( odPrimeUrlMetricsVerificationToken ) { resolve(); } @@ -1131,7 +1169,7 @@ export default async function detect( { ); } url.searchParams.set( 'hmac', urlMetricHMAC ); - if ( '' !== odPrimeUrlMetricsVerificationToken ) { + if ( odPrimeUrlMetricsVerificationToken ) { url.searchParams.set( 'prime_url_metrics_verification_token', odPrimeUrlMetricsVerificationToken @@ -1149,17 +1187,22 @@ export default async function detect( { method: 'POST', body: payloadBlob, headers, - keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon(). + keepalive: odPrimeUrlMetricsVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon(). } ); - await fetch( request ); - if ( '' !== odPrimeUrlMetricsVerificationToken ) { - window.parent.postMessage( - 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', - '*' - ); - document.dispatchEvent( - new CustomEvent( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' ) - ); + if ( ! odPrimeUrlMetricsVerificationToken ) { + await fetch( request ); + } else { + try { + const response = await fetch( request ); + if ( ! response.ok ) { + throw new Error( + `Failed to send URL Metric. Status: ${ response.status }` + ); + } + notifyStatus( { success: true } ); + } catch ( err ) { + notifyStatus( { success: false, error: err.message } ); + } } } diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 631805e96d..2faf3be126 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -15,6 +15,7 @@ let isTabHidden = false; let abortController = new AbortController(); let processTasksPromise = null; + const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -28,8 +29,19 @@ iframe.style.zIndex = '-9999'; document.body.appendChild( iframe ); + /** + * Logs messages to the console. + * + * @param {...*} message - The message(s) to log. + */ + function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); + } + /** * Primes the URL metrics for all breakpoints. + * * @return {Promise} The promise that resolves to void. */ async function processTasks() { @@ -55,10 +67,17 @@ } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { - await processTask( - currentTasks[ currentTaskIndex ], - abortController.signal - ); + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + } catch ( error ) { + log( error.message ); + if ( 'Task Aborted' === error.message ) { + throw error; + } + } currentTaskIndex++; } isProcessing = false; @@ -69,24 +88,48 @@ /** * Loads the iframe and waits for the message. + * * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ async function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { + /** + * Handles the message from the iframe. + * @param {MessageEvent} event - The message event. + */ const handleMessage = ( event ) => { - if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { - cleanup(); - resolve(); + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + cleanup(); + resolve(); + } else { + cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } } }; + /** + * Handles the aborting of the task on abort signal. + */ const abortHandler = () => { cleanup(); reject( new Error( 'Task Aborted' ) ); }; + /** + * Cleans up the event listeners and iframe. + */ const cleanup = () => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); @@ -113,7 +156,7 @@ reject( new Error( 'Iframe failed to load' ) ); }; - // Load the iframe + // Load the iframe. iframe.src = task.url; iframe.width = task.width.toString(); iframe.height = task.height.toString(); diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 1ddecedc34..93bc0959bd 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -19,6 +19,7 @@ let currentTaskIndex = 0; let isTabHidden = false; let abortController = new AbortController(); + const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -32,8 +33,19 @@ iframe.style.zIndex = '-9999'; document.body.appendChild( iframe ); + /** + * Logs messages to the console. + * + * @param {...*} message - The message(s) to log. + */ + function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); + } + /** * Primes the URL metrics for all breakpoints. + * * @return {Promise} The promise that resolves to void. */ async function processTasks() { @@ -58,10 +70,17 @@ } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { - await processTask( - currentTasks[ currentTaskIndex ], - abortController.signal - ); + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + } catch ( error ) { + log( error ); + if ( 'Task Aborted' === error.message ) { + throw error; + } + } currentTaskIndex++; } isProcessing = false; @@ -72,24 +91,48 @@ /** * Loads the iframe and waits for the message. + * * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { + /** + * Handles the message from the iframe. + * @param {MessageEvent} event - The message event. + */ const handleMessage = ( event ) => { - if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { - cleanup(); - resolve(); + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + cleanup(); + resolve(); + } else { + cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } } }; + /** + * Handles the aborting of the task on abort signal. + */ const abortHandler = () => { cleanup(); reject( new Error( 'Task Aborted' ) ); }; + /** + * Cleans up the event listeners and iframe. + */ const cleanup = () => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); @@ -116,7 +159,7 @@ reject( new Error( 'Iframe failed to load' ) ); }; - // Load the iframe + // Load the iframe. iframe.src = task.url; iframe.width = task.width.toString(); iframe.height = task.height.toString(); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index c284296177..24af4387b9 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -66,6 +66,17 @@ let currentBatchNumber = 0; let isTabHidden = false; let abortController = new AbortController(); + const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; + + /** + * Logs messages to the console. + * + * @param {...*} message - The message(s) to log. + */ + function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); + } /** * Toggles the processing state. @@ -181,19 +192,27 @@ */ async function processCurrentBatch() { while ( isProcessing && currentTaskIndex < currentTasks.length ) { - await processTask( - currentTasks[ currentTaskIndex ], - abortController.signal - ); + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); - currentTaskIndex++; - progressBar.value = currentTaskIndex; - currentTaskElement.textContent = currentTaskIndex.toString(); + currentTaskIndex++; + progressBar.value = currentTaskIndex; + currentTaskElement.textContent = currentTaskIndex.toString(); + } catch ( error ) { + log( error.message ); + if ( 'Task Aborted' === error.message ) { + throw error; + } + } } } /** * Flattens the batch to tasks. + * * @param {Object} batch - The batch to flatten. * @return {Array<{ url: string, width: number, height: number }>} - The flattened tasks. */ @@ -213,6 +232,7 @@ /** * Fetches the next batch of URLs. + * * @param {Object} lastCursor - The cursor to fetch the next batch. * @return {Promise} - The promise that resolves to the batch of URLs. */ @@ -227,24 +247,49 @@ /** * Loads the iframe and waits for the message. + * * @param {{ url: string, width: number, height: number }} task - The breakpoint to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ function processTask( task, signal ) { return new Promise( ( resolve, reject ) => { + /** + * Handles the message from the iframe. + * + * @param {MessageEvent} event - The message event. + */ const handleMessage = ( event ) => { - if ( 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS' === event.data ) { - cleanup(); - resolve(); + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + cleanup(); + resolve(); + } else { + cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } } }; + /** + * Handles the aborting of the task on abort signal. + */ const abortHandler = () => { cleanup(); reject( new Error( 'Task Aborted' ) ); }; + /** + * Cleans up the event listeners and iframe. + */ const cleanup = () => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); @@ -279,6 +324,9 @@ verificationToken; if ( isDebug ) { + /** + * Fits the iframe to the container. + */ function fitIframe() { const containerWidth = iframeContainer.clientWidth; if ( containerWidth <= 0 ) { diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index a0d3dc2a51..5c022852af 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -166,12 +166,28 @@ async function processTask( page, task, verificationToken, abortSignal ) { ); }, 30000 ); - document.addEventListener( - 'OD_PRIME_URL_METRICS_REQUEST_SUCCESS', - async () => { + /** + * Handles the message from the page. + * @param {CustomEvent} event - The message event. + */ + function handleMessage( event ) { + if ( event.detail && event.detail.success ) { clearTimeout( timeoutId ); requestSuccessResolve(); - }, + } else { + clearTimeout( timeoutId ); + requestSuccessReject( + new Error( + event.detail.error || + 'URL Metric request failed' + ) + ); + } + } + + document.addEventListener( + 'OD_PRIME_URL_METRICS_REQUEST_STATUS', + handleMessage, { once: true } ); } From 4f93e1424a8c48189c7e9cdce28ca87ab847570a Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 17 Apr 2025 14:37:16 +0530 Subject: [PATCH 43/62] Refactor to use URL fragment for passing verification token Co-authored-by: Weston Ruter --- plugins/optimization-detective/detect.js | 33 +++-------- .../prime-url-metrics-block-editor.js | 52 ++++++++++------ .../prime-url-metrics-classic-editor.js | 52 ++++++++++------ .../prime-url-metrics.js | 59 ++++++++++++------- .../priming-cli/index.js | 50 +++++++++++++--- .../priming-cli/tsconfig.json | 12 ---- .../priming-cli/types.d.ts | 7 --- 7 files changed, 156 insertions(+), 109 deletions(-) delete mode 100644 plugins/optimization-detective/priming-cli/tsconfig.json delete mode 100644 plugins/optimization-detective/priming-cli/types.d.ts diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index bd5e29cdb4..67985dfd6f 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -639,32 +639,13 @@ export default async function detect( { return; } - try { - if ( - win.parent && - win.location.origin === win.parent.location.origin - ) { - /** @type {HTMLIFrameElement|null} */ - const urlPrimeIframeElement = win.parent.document.querySelector( - 'iframe#od-prime-url-metrics-iframe' - ); - if ( - urlPrimeIframeElement && - urlPrimeIframeElement.dataset.odPrimeUrlMetricsVerificationToken - ) { - odPrimeUrlMetricsVerificationToken = - urlPrimeIframeElement.dataset - .odPrimeUrlMetricsVerificationToken; - } - } - } catch ( e ) { - // Ignoring error caused possibly due to cross-origin iframe access. - } - - // Only available when page is loaded by Puppeteer script. - if ( win.__odPrimeUrlMetricsVerificationToken ) { - odPrimeUrlMetricsVerificationToken = - win.__odPrimeUrlMetricsVerificationToken; + // Retrieve verification token from the URL hash for priming URL Metrics. + // Presence of the token indicates that the URL Metric is being primed + // through the Puppeteer script or WordPress admin dashboard. + if ( '' !== window.location.hash ) { + odPrimeUrlMetricsVerificationToken = new URLSearchParams( + window.location.hash.slice( 1 ) + ).get( 'odPrimeUrlMetricsVerificationToken' ); } // Abort if the client already submitted a URL Metric for this URL and viewport group. diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 2faf3be126..0e807354f0 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -97,19 +97,21 @@ return new Promise( ( resolve, reject ) => { /** * Handles the message from the iframe. + * * @param {MessageEvent} event - The message event. + * @return {Promise} The promise that resolves to void. */ - const handleMessage = ( event ) => { + const handleMessage = async ( event ) => { if ( event.data && event.data.type && 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type ) { if ( event.data.success ) { - cleanup(); + await cleanup(); resolve(); } else { - cleanup(); + await cleanup(); reject( new Error( event.data.error || 'URL Metric request failed' @@ -121,25 +123,38 @@ /** * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. */ - const abortHandler = () => { - cleanup(); + const abortHandler = async () => { + await cleanup(); reject( new Error( 'Task Aborted' ) ); }; /** * Cleans up the event listeners and iframe. + * + * @return {Promise} The promise that resolves to void. */ const cleanup = () => { - signal.removeEventListener( 'abort', abortHandler ); - window.removeEventListener( 'message', handleMessage ); - clearTimeout( timeoutId ); - iframe.onerror = null; - iframe.src = 'about:blank'; + return new Promise( ( cleanUpResolve ) => { + signal.removeEventListener( 'abort', abortHandler ); + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + iframe.src = 'about:blank'; + iframe.addEventListener( + 'load', + () => { + cleanUpResolve(); + }, + { once: true } + ); + } ); }; - const timeoutId = setTimeout( () => { - cleanup(); + const timeoutId = setTimeout( async () => { + await cleanup(); reject( new Error( 'Timeout waiting for message' ) ); }, 30000 ); // 30-second timeout @@ -151,17 +166,20 @@ signal.addEventListener( 'abort', abortHandler ); window.addEventListener( 'message', handleMessage ); - iframe.onerror = () => { - cleanup(); + iframe.onerror = async () => { + await cleanup(); reject( new Error( 'Iframe failed to load' ) ); }; + const url = new URL( task.url ); + url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + verificationToken + ) }`; + // Load the iframe. - iframe.src = task.url; + iframe.src = url.toString(); iframe.width = task.width.toString(); iframe.height = task.height.toString(); - iframe.dataset.odPrimeUrlMetricsVerificationToken = - verificationToken; } ); } diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 93bc0959bd..f8f8453bf9 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -100,19 +100,21 @@ return new Promise( ( resolve, reject ) => { /** * Handles the message from the iframe. + * * @param {MessageEvent} event - The message event. + * @return {Promise} The promise that resolves to void. */ - const handleMessage = ( event ) => { + const handleMessage = async ( event ) => { if ( event.data && event.data.type && 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type ) { if ( event.data.success ) { - cleanup(); + await cleanup(); resolve(); } else { - cleanup(); + await cleanup(); reject( new Error( event.data.error || 'URL Metric request failed' @@ -124,25 +126,38 @@ /** * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. */ - const abortHandler = () => { - cleanup(); + const abortHandler = async () => { + await cleanup(); reject( new Error( 'Task Aborted' ) ); }; /** * Cleans up the event listeners and iframe. + * + * @return {Promise} The promise that resolves to void. */ const cleanup = () => { - signal.removeEventListener( 'abort', abortHandler ); - window.removeEventListener( 'message', handleMessage ); - clearTimeout( timeoutId ); - iframe.onerror = null; - iframe.src = 'about:blank'; + return new Promise( ( cleanUpResolve ) => { + signal.removeEventListener( 'abort', abortHandler ); + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + iframe.src = 'about:blank'; + iframe.addEventListener( + 'load', + () => { + cleanUpResolve(); + }, + { once: true } + ); + } ); }; - const timeoutId = setTimeout( () => { - cleanup(); + const timeoutId = setTimeout( async () => { + await cleanup(); reject( new Error( 'Timeout waiting for message' ) ); }, 30000 ); // 30-second timeout @@ -154,17 +169,20 @@ signal.addEventListener( 'abort', abortHandler ); window.addEventListener( 'message', handleMessage ); - iframe.onerror = () => { - cleanup(); + iframe.onerror = async () => { + await cleanup(); reject( new Error( 'Iframe failed to load' ) ); }; + const url = new URL( task.url ); + url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + verificationToken + ) }`; + // Load the iframe. - iframe.src = task.url; + iframe.src = url.toString(); iframe.width = task.width.toString(); iframe.height = task.height.toString(); - iframe.dataset.odPrimeUrlMetricsVerificationToken = - verificationToken; } ); } diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 24af4387b9..1e933c47a2 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -197,15 +197,15 @@ currentTasks[ currentTaskIndex ], abortController.signal ); - - currentTaskIndex++; - progressBar.value = currentTaskIndex; - currentTaskElement.textContent = currentTaskIndex.toString(); } catch ( error ) { log( error.message ); if ( 'Task Aborted' === error.message ) { throw error; } + } finally { + currentTaskIndex++; + progressBar.value = currentTaskIndex; + currentTaskElement.textContent = currentTaskIndex.toString(); } } } @@ -258,18 +258,19 @@ * Handles the message from the iframe. * * @param {MessageEvent} event - The message event. + * @return {Promise} The promise that resolves to void. */ - const handleMessage = ( event ) => { + const handleMessage = async ( event ) => { if ( event.data && event.data.type && 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type ) { if ( event.data.success ) { - cleanup(); + await cleanup(); resolve(); } else { - cleanup(); + await cleanup(); reject( new Error( event.data.error || 'URL Metric request failed' @@ -281,25 +282,38 @@ /** * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. */ - const abortHandler = () => { - cleanup(); + const abortHandler = async () => { + await cleanup(); reject( new Error( 'Task Aborted' ) ); }; /** * Cleans up the event listeners and iframe. + * + * @return {Promise} The promise that resolves to void. */ const cleanup = () => { - signal.removeEventListener( 'abort', abortHandler ); - window.removeEventListener( 'message', handleMessage ); - clearTimeout( timeoutId ); - iframe.onerror = null; - iframe.src = 'about:blank'; + return new Promise( ( cleanUpResolve ) => { + signal.removeEventListener( 'abort', abortHandler ); + window.removeEventListener( 'message', handleMessage ); + clearTimeout( timeoutId ); + iframe.onerror = null; + iframe.src = 'about:blank'; + iframe.addEventListener( + 'load', + () => { + cleanUpResolve(); + }, + { once: true } + ); + } ); }; - const timeoutId = setTimeout( () => { - cleanup(); + const timeoutId = setTimeout( async () => { + await cleanup(); reject( new Error( 'Timeout waiting for message' ) ); }, 30000 ); // 30-second timeout @@ -311,17 +325,20 @@ signal.addEventListener( 'abort', abortHandler ); window.addEventListener( 'message', handleMessage ); - iframe.onerror = () => { - cleanup(); + iframe.onerror = async () => { + await cleanup(); reject( new Error( 'Iframe failed to load' ) ); }; + const url = new URL( task.url ); + url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + verificationToken + ) }`; + // Load the iframe. - iframe.src = task.url; + iframe.src = url.toString(); iframe.width = task.width.toString(); iframe.height = task.height.toString(); - iframe.dataset.odPrimeUrlMetricsVerificationToken = - verificationToken; if ( isDebug ) { /** diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 5c022852af..db439ffe10 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -13,18 +13,21 @@ program /** * Instance of ora spinner for displaying status messages. + * * @type {import('ora').Ora} */ const spinner = ora( 'Starting...' ).start(); /** * Instance of AbortController to handle aborting tasks. + * * @type {AbortController} */ const abortController = new AbortController(); /** * Abort signal to be used to detect abort events. + * * @type {AbortSignal} */ const signal = abortController.signal; @@ -37,6 +40,7 @@ process.on( 'SIGINT', async () => { /** * Checks the environment for required tools and plugins. + * * @return {boolean} - True if all checks passed, false otherwise. */ function checkEnvironment() { @@ -78,6 +82,7 @@ function checkEnvironment() { /** * Fetches the next batch of URLs. + * * @param {Object} lastCursor - The cursor to fetch the next batch. * @return {?Object} - The batch of URLs. */ @@ -103,6 +108,7 @@ function getBatch( lastCursor ) { /** * Flattens the batch into individual tasks. + * * @param {Object} batch - The batch to flatten. * @return {Array<{ url: string, width: number, height: number }>} The list of tasks. */ @@ -135,23 +141,48 @@ async function processTask( page, task, verificationToken, abortSignal ) { * Handles the abort event. */ function onAbort() { - reject( new Error( 'Task aborted.' ) ); + page.evaluate( () => { + window.dispatchEvent( + new CustomEvent( 'OD_PRIME_URL_METRICS_REQUEST_STATUS', { + detail: { + success: false, + error: 'Task aborted.', + }, + } ) + ); + } ); } abortSignal.addEventListener( 'abort', onAbort ); + /** + * Cleans up the page and abort signal listeners. + * + * @return {Promise} The promise that resolves to void. + */ + async function cleanup() { + abortSignal.removeEventListener( 'abort', onAbort ); + await page.goto( 'about:blank', { + waitUntil: 'load', + timeout: 30000, + signal: abortSignal, + } ); + } + try { await page.setViewport( { width: task.width, height: task.height, } ); - await page.evaluateOnNewDocument( ( token ) => { - window.__odPrimeUrlMetricsVerificationToken = token; - }, verificationToken ); + const url = new URL( task.url ); + url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + verificationToken + ) }`; - await page.goto( task.url, { + await page.goto( url.toString(), { waitUntil: 'load', timeout: 30000, + signal: abortSignal, } ); await page.evaluate( () => { @@ -168,6 +199,7 @@ async function processTask( page, task, verificationToken, abortSignal ) { /** * Handles the message from the page. + * * @param {CustomEvent} event - The message event. */ function handleMessage( event ) { @@ -193,11 +225,12 @@ async function processTask( page, task, verificationToken, abortSignal ) { } ); } ); + + await cleanup(); + resolve(); } catch ( error ) { + await cleanup(); reject( error ); - } finally { - abortSignal.removeEventListener( 'abort', onAbort ); - resolve(); } } ); } @@ -266,7 +299,6 @@ async function init() { cursor = currentBatch.cursor; } - // Close the browser. await browser.close(); if ( signal.aborted ) { diff --git a/plugins/optimization-detective/priming-cli/tsconfig.json b/plugins/optimization-detective/priming-cli/tsconfig.json deleted file mode 100644 index 804a4cadc9..0000000000 --- a/plugins/optimization-detective/priming-cli/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "checkJs": true, - "noEmit": true, - "allowJs": true, - "target": "es2020", - "moduleResolution": "node", - "module": "esnext", - "skipLibCheck": true - }, - "include": [ "index.js", "types.d.ts" ] -} diff --git a/plugins/optimization-detective/priming-cli/types.d.ts b/plugins/optimization-detective/priming-cli/types.d.ts deleted file mode 100644 index 41d9418e92..0000000000 --- a/plugins/optimization-detective/priming-cli/types.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {}; - -declare global { - interface Window { - __odPrimeUrlMetricsVerificationToken: string; - } -} From ff6ad0f4467fc673110dbd100ad7b1b6dc93680c Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 18 Apr 2025 01:24:49 +0530 Subject: [PATCH 44/62] Add source property to URL Metric schema --- .../class-od-url-metric.php | 24 ++++++++++++++++++- plugins/optimization-detective/detect.js | 5 ++++ plugins/optimization-detective/types.ts | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index b8c4374af8..14b21ab35d 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -43,7 +43,8 @@ * url: non-empty-string, * timestamp: float, * viewport: ViewportRect, - * elements: ElementData[] + * elements: ElementData[], + * source?: non-empty-string, * } * @phpstan-type JSONSchema array{ * type: string|string[], @@ -310,6 +311,16 @@ public static function get_json_schema(): array { 'additionalProperties' => true, ), ), + 'source' => array( + 'description' => __( 'The source of the URL Metric.', 'optimization-detective' ), + 'type' => 'string', + 'required' => false, + 'enum' => array( + 'visitor', + 'user', + 'synthetic', + ), + ), ), // Additional root properties may be added to the schema via the od_url_metric_schema_root_additional_properties filter. // Therefore, additionalProperties is set to true so that additional properties defined in the extended schema may persist @@ -526,6 +537,17 @@ function ( array $element ): OD_Element { return $this->elements; } + /** + * Gets the source of the URL Metric. + * + * @since n.e.x.t + * + * @return non-empty-string|null Source. + */ + public function get_source(): ?string { + return $this->data['source'] ?? null; + } + /** * Specifies data which should be serialized to JSON. * diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 67985dfd6f..025818d3c4 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -841,8 +841,13 @@ export default async function detect( { height: win.innerHeight, }, elements: [], + source: restApiNonce ? 'user' : 'visitor', }; + if ( odPrimeUrlMetricsVerificationToken ) { + urlMetric.source = 'synthetic'; + } + const lcpMetric = lcpMetricCandidates.at( -1 ); // Populate the elements in the URL Metric. diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index 39533aed39..bcdef52ab1 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -28,6 +28,7 @@ export interface URLMetric { height: number; }; elements: ElementData[]; + source: 'visitor' | 'user' | 'synthetic'; } export type ExtendedRootData = ExcludeProps< URLMetric >; From ec5dcd774cc359a5311e7f8272e37a82afddf21b Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 22 Apr 2025 00:13:32 +0530 Subject: [PATCH 45/62] Add auto scrolling for URL Metric priming mode --- plugins/optimization-detective/detect.js | 76 +++++++++++++++++++----- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 025818d3c4..60a9c9c95b 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -547,6 +547,51 @@ function notifyStatus( status, options = { toParent: true, toLocal: true } ) { } } +/** + * Scrolls to the bottom of the page. + * + * @return {Promise} A promise that resolves when the scroll is complete. + */ +async function scrollToBottomOfPage() { + return new Promise( ( resolve ) => { + const viewportHeight = window.innerHeight; + const maxScrollAttempts = 20; + const maxHeightChangeAmount = viewportHeight * 0.5; + const maxHeightChangeAttempts = 3; + + let scrollAttempts = 0; + let heightChangeAttempts = 0; + let lastScrollPosition = 0; + let lastHeight = document.documentElement.scrollHeight; + + function scroll() { + const newHeight = document.documentElement.scrollHeight; + if ( + window.innerHeight + window.scrollY >= newHeight - 10 || + scrollAttempts >= maxScrollAttempts || + heightChangeAttempts >= maxHeightChangeAttempts + ) { + resolve(); + return; + } + + lastScrollPosition += viewportHeight; + window.scrollTo( { top: lastScrollPosition, behavior: 'smooth' } ); + + const heightChange = newHeight - lastHeight; + if ( heightChange >= maxHeightChangeAmount ) { + lastHeight = newHeight; + heightChangeAttempts++; + } + + scrollAttempts++; + setTimeout( scroll, 300 ); + } + + scroll(); + } ); +} + /** * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData * @typedef {{groups: Array<{url_metrics: Array}>}} CollectionDebugData @@ -980,23 +1025,24 @@ export default async function detect( { debounceCompressUrlMetric(); // Wait for the page to be hidden. - await new Promise( ( resolve ) => { - if ( odPrimeUrlMetricsVerificationToken ) { + await new Promise( async ( resolve ) => { + if ( ! odPrimeUrlMetricsVerificationToken ) { + win.addEventListener( 'pagehide', resolve, { once: true } ); + win.addEventListener( 'pageswap', resolve, { once: true } ); + doc.addEventListener( + 'visibilitychange', + () => { + if ( document.visibilityState === 'hidden' ) { + // TODO: This will fire even when switching tabs. + resolve(); + } + }, + { once: true } + ); + } else { + await scrollToBottomOfPage(); resolve(); } - - win.addEventListener( 'pagehide', resolve, { once: true } ); - win.addEventListener( 'pageswap', resolve, { once: true } ); - doc.addEventListener( - 'visibilitychange', - () => { - if ( document.visibilityState === 'hidden' ) { - // TODO: This will fire even when switching tabs. - resolve(); - } - }, - { once: true } - ); } ); // Only proceed with submitting the URL Metric if viewport stayed the same size. Changing the viewport size (e.g. due From 2550f96c276093305a67f1e96d7fab775c7e5c16 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 22 Apr 2025 14:02:42 +0530 Subject: [PATCH 46/62] Add function to force URL Metrics compression when in priming mode --- plugins/optimization-detective/detect.js | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 60a9c9c95b..06dc5ec251 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -515,6 +515,39 @@ function debounceCompressUrlMetric() { }, compressionDebounceWaitDuration ); } +/** + * Forces immediate compression of the URL Metric, bypassing debounce and idle callbacks. + * + * @return {Promise} A promise that resolves when the compression is complete. + */ +async function forceCompressUrlMetric() { + if ( ! compressionEnabled ) { + return null; + } + if ( null !== recompressionTimeout ) { + clearTimeout( recompressionTimeout ); + recompressionTimeout = null; + } + if ( + null !== idleCallbackHandle && + typeof cancelIdleCallback === 'function' + ) { + cancelIdleCallback( idleCallbackHandle ); + idleCallbackHandle = null; + } + + try { + compressedPayload = await compress( JSON.stringify( urlMetric ) ); + } catch ( err ) { + const { error } = createLogger( false, consoleLogPrefix ); + error( + 'Failed to compress URL Metric falling back to sending uncompressed data:', + err + ); + compressionEnabled = false; + } +} + /** * Notifies about the URL Metric request status. * @@ -1041,6 +1074,7 @@ export default async function detect( { ); } else { await scrollToBottomOfPage(); + await forceCompressUrlMetric(); resolve(); } } ); From 5e64b3934a2e65aaebd9323d9d027e971d23ce50 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 23 Apr 2025 23:16:09 +0530 Subject: [PATCH 47/62] Refactor to make `source` property readonly Co-authored-by: Weston Ruter --- plugins/optimization-detective/class-od-url-metric.php | 5 +++-- plugins/optimization-detective/detect.js | 5 ----- .../storage/class-od-rest-url-metrics-store-endpoint.php | 1 + plugins/optimization-detective/types.ts | 1 - 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 14b21ab35d..e9cc43d575 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -44,7 +44,7 @@ * timestamp: float, * viewport: ViewportRect, * elements: ElementData[], - * source?: non-empty-string, + * source?: 'visitor'|'user'|'synthetic', * } * @phpstan-type JSONSchema array{ * type: string|string[], @@ -315,6 +315,7 @@ public static function get_json_schema(): array { 'description' => __( 'The source of the URL Metric.', 'optimization-detective' ), 'type' => 'string', 'required' => false, + 'readonly' => true, // Omit from REST API. 'enum' => array( 'visitor', 'user', @@ -542,7 +543,7 @@ function ( array $element ): OD_Element { * * @since n.e.x.t * - * @return non-empty-string|null Source. + * @return 'visitor'|'user'|'synthetic'|null Source. */ public function get_source(): ?string { return $this->data['source'] ?? null; diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 06dc5ec251..22c8cc0492 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -919,13 +919,8 @@ export default async function detect( { height: win.innerHeight, }, elements: [], - source: restApiNonce ? 'user' : 'visitor', }; - if ( odPrimeUrlMetricsVerificationToken ) { - urlMetric.source = 'synthetic'; - } - const lcpMetric = lcpMetricCandidates.at( -1 ); // Populate the elements in the URL Metric. diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php index e67b5e80b5..b7186dd46a 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php @@ -228,6 +228,7 @@ public function handle_rest_request( WP_REST_Request $request ) { 'timestamp' => microtime( true ), 'uuid' => wp_generate_uuid4(), 'etag' => $request->get_param( 'current_etag' ), + 'source' => '' !== (string) $request->get_param( 'prime_url_metrics_verification_token' ) ? 'synthetic' : ( is_user_logged_in() ? 'user' : 'visitor' ), ) ) ); diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index bcdef52ab1..39533aed39 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -28,7 +28,6 @@ export interface URLMetric { height: number; }; elements: ElementData[]; - source: 'visitor' | 'user' | 'synthetic'; } export type ExtendedRootData = ExcludeProps< URLMetric >; From 7d42cac105b276eaebd26e755498290120d806e3 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 24 Apr 2025 13:13:46 +0530 Subject: [PATCH 48/62] Fix failing test because of new `source` property Co-authored-by: Weston Ruter --- .../tests/test-class-od-url-metric.php | 23 ++++++++++++++++++- .../tests/test-class-od-url-metrics-group.php | 17 ++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 6eac020495..1d9e672c81 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metric.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metric.php @@ -31,6 +31,7 @@ public function data_provider_to_test_constructor(): array { return array( 'valid_minimal' => array( 'data' => array( + // Note: The 'source' field is currently optional, so this data is still valid without it. 'url' => home_url( '/' ), 'etag' => md5( '' ), 'viewport' => $viewport, @@ -136,6 +137,17 @@ static function ( $value ) { ), 'error' => 'etag is a required property of OD_URL_Metric.', ), + 'missing_source' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + // Note: Add error message 'source is a required property of OD_URL_Metric.' when 'source' becomes mandatory. + ), 'missing_viewport' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), @@ -331,6 +343,15 @@ static function ( OD_Element $element ) { $this->assertSame( $data['etag'], $url_metric->get( 'etag' ) ); $this->assertTrue( 1 === preg_match( '/^[a-f0-9]{32}$/', $url_metric->get_etag() ) ); + // Note: When the 'source' field becomes required, the else statement can be removed. + if ( array_key_exists( 'source', $data ) ) { + $this->assertSame( $data['source'], $url_metric->get_source() ); + $this->assertSame( $data['source'], $url_metric->get( 'source' ) ); + $this->assertContains( $url_metric->get_source(), array( 'visitor', 'user', 'synthetic' ) ); + } else { + $this->assertNull( $url_metric->get_source() ); + } + $this->assertTrue( wp_is_uuid( $url_metric->get_uuid() ) ); $this->assertSame( $url_metric->get_uuid(), $url_metric->get( 'uuid' ) ); @@ -919,7 +940,7 @@ public function test_get_json_schema_extensibility( Closure $set_up, Closure $as */ protected function check_schema_subset( array $schema, string $path, bool $extended = false ): void { $this->assertArrayHasKey( 'required', $schema, $path ); - if ( ! $extended ) { + if ( ! $extended && 'root/source' !== $path ) { $this->assertTrue( $schema['required'], $path ); } $this->assertArrayHasKey( 'type', $schema, $path ); 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 0b4e2b0db9..bd5ca5cfe4 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 @@ -295,6 +295,23 @@ public function data_provider_test_is_complete(): array { 'freshness_ttl' => -1, 'expected_is_group_complete' => false, ), + // Note: The following test case will not be required once the 'source' is mandatory in a future release. + 'source_missing' => array( + 'url_metric' => new OD_URL_Metric( + array( + 'url' => home_url( '/' ), + 'etag' => md5( '' ), + 'viewport' => array( + 'width' => 400, + 'height' => 700, + ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ) + ), + 'freshness_ttl' => HOUR_IN_SECONDS, + 'expected_is_group_complete' => true, + ), ); } From d21ac27561d916b154570fcc76eb4cf768ec4d8c Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 25 Apr 2025 14:20:39 +0530 Subject: [PATCH 49/62] Improve logging for failed tasks, Improve DOC comment of variables --- .../priming-cli/index.js | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index db439ffe10..d924cc3a0c 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -14,6 +14,9 @@ program /** * Instance of ora spinner for displaying status messages. * + * @see {init} + * @see {getBatch} + * @see {checkEnvironment} * @type {import('ora').Ora} */ const spinner = ora( 'Starting...' ).start(); @@ -21,6 +24,7 @@ const spinner = ora( 'Starting...' ).start(); /** * Instance of AbortController to handle aborting tasks. * + * @see {getBatch} * @type {AbortController} */ const abortController = new AbortController(); @@ -28,12 +32,13 @@ const abortController = new AbortController(); /** * Abort signal to be used to detect abort events. * + * @see {processTask} * @type {AbortSignal} */ const signal = abortController.signal; // Listen for the SIGINT signal (Ctrl+C) to abort the process. -process.on( 'SIGINT', async () => { +process.on( 'SIGINT', () => { spinner.start( 'Aborting...' ); abortController.abort(); } ); @@ -240,11 +245,46 @@ async function processTask( page, task, verificationToken, abortSignal ) { * @return {Promise} */ async function init() { + /** + * Puppeteer browser instance used for headless page navigation and rendering. + * + * @type {import('puppeteer').Browser} + */ const browser = await launch( { headless: true } ); + + /** + * Main Puppeteer page object used to navigate to URLs. + * + * @type {import('puppeteer').Page} + */ const browserPage = await browser.newPage(); + + /** + * Flag indicating whether more URL batches are available for processing. + * + * @type {boolean} + */ let isNextBatchAvailable = true; + + /** + * Cursor object to track position in pagination when fetching URL batches. + * + * @type {Object} + */ let cursor = {}; + + /** + * Counter tracking the number of URL batches processed so far. + * + * @type {number} + */ let currentBatchNumber = 0; + + /** + * Token used to verify REST API requests server side when in priming mode. + * + * @type {string} + */ let verificationToken; // Process batches until no more are available. @@ -252,7 +292,7 @@ async function init() { if ( signal.aborted ) { break; } - spinner.text = 'Fetching next batch'; + spinner.start( 'Fetching next batch' ); const currentBatch = await getBatch( cursor ); // If no URLs remain in the batch, finish processing. @@ -278,11 +318,13 @@ async function init() { } const task = currentTasks[ i ]; - spinner.text = `Processing task ${ chalk.green( - i + 1 + '/' + currentTasks.length - ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( - task.width + 'x' + task.height - ) }`; + spinner.start( + `Processing task ${ chalk.green( + i + 1 + '/' + currentTasks.length + ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( + task.width + 'x' + task.height + ) }` + ); try { await processTask( browserPage, @@ -291,9 +333,12 @@ async function init() { signal ); } catch ( error ) { - spinner.text = `Error processing task ${ i + 1 }. Error: ${ - error.message - }`; + // Log the error and continue processing the next task. + spinner.fail( + `Error processing task ${ i + 1 }. Error: ${ + error.message + }` + ); } } cursor = currentBatch.cursor; From 3b41a120570b49ef561ddddde71ffaa72bf154c8 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 25 Apr 2025 14:24:19 +0530 Subject: [PATCH 50/62] Standardize functions and improve event handling in priming helper scripts --- .../prime-url-metrics-block-editor.js | 42 +++++---- .../prime-url-metrics-classic-editor.js | 56 ++++++------ .../prime-url-metrics.js | 91 ++++++++++--------- .../priming-cli/.gitignore | 78 ---------------- 4 files changed, 98 insertions(+), 169 deletions(-) delete mode 100644 plugins/optimization-detective/priming-cli/.gitignore diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 0e807354f0..44836ccc0a 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -15,7 +15,7 @@ let isTabHidden = false; let abortController = new AbortController(); let processTasksPromise = null; - const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; + const consoleLogPrefix = '[Optimization Detective Priming Mode]'; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -101,7 +101,7 @@ * @param {MessageEvent} event - The message event. * @return {Promise} The promise that resolves to void. */ - const handleMessage = async ( event ) => { + async function handleMessage( event ) { if ( event.data && event.data.type && @@ -119,39 +119,35 @@ ); } } - }; + } /** * Handles the aborting of the task on abort signal. * * @return {Promise} The promise that resolves to void. */ - const abortHandler = async () => { + async function abortHandler() { await cleanup(); reject( new Error( 'Task Aborted' ) ); - }; + } /** * Cleans up the event listeners and iframe. * * @return {Promise} The promise that resolves to void. */ - const cleanup = () => { + function cleanup() { return new Promise( ( cleanUpResolve ) => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; iframe.src = 'about:blank'; - iframe.addEventListener( - 'load', - () => { - cleanUpResolve(); - }, - { once: true } - ); + iframe.addEventListener( 'load', () => cleanUpResolve(), { + once: true, + } ); } ); - }; + } const timeoutId = setTimeout( async () => { await cleanup(); @@ -207,9 +203,9 @@ } ); /** - * Pause processing when the tab/window becomes hidden. + * Handles visibility change events to pause/resume processing when tab/window visibility changes. */ - document.addEventListener( 'visibilitychange', () => { + function handleVisibilityChange() { if ( 'hidden' === document.visibilityState && isProcessing ) { isProcessing = false; isTabHidden = true; @@ -226,14 +222,20 @@ processTasks(); } } - } ); + } /** - * Prevent the user from leaving the page while processing. + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload event */ - window.addEventListener( 'beforeunload', function ( event ) { + function handleBeforeUnload( event ) { if ( isProcessing ) { event.preventDefault(); } - } ); + } + + // Attach event listeners. + document.addEventListener( 'visibilitychange', handleVisibilityChange ); + window.addEventListener( 'beforeunload', handleBeforeUnload ); } )(); diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index f8f8453bf9..b57ce2b63f 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -19,7 +19,7 @@ let currentTaskIndex = 0; let isTabHidden = false; let abortController = new AbortController(); - const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; + const consoleLogPrefix = '[Optimization Detective Priming Mode]'; const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; @@ -104,7 +104,7 @@ * @param {MessageEvent} event - The message event. * @return {Promise} The promise that resolves to void. */ - const handleMessage = async ( event ) => { + async function handleMessage( event ) { if ( event.data && event.data.type && @@ -122,39 +122,35 @@ ); } } - }; + } /** * Handles the aborting of the task on abort signal. * * @return {Promise} The promise that resolves to void. */ - const abortHandler = async () => { + async function abortHandler() { await cleanup(); reject( new Error( 'Task Aborted' ) ); - }; + } /** * Cleans up the event listeners and iframe. * * @return {Promise} The promise that resolves to void. */ - const cleanup = () => { + function cleanup() { return new Promise( ( cleanUpResolve ) => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; iframe.src = 'about:blank'; - iframe.addEventListener( - 'load', - () => { - cleanUpResolve(); - }, - { once: true } - ); + iframe.addEventListener( 'load', () => cleanUpResolve(), { + once: true, + } ); } ); - }; + } const timeoutId = setTimeout( async () => { await cleanup(); @@ -187,17 +183,9 @@ } /** - * Primes the URL metrics for all breakpoints - * when the document is ready. + * Handles visibility change events to pause/resume processing when tab/window visibility changes. */ - document.addEventListener( 'DOMContentLoaded', () => { - processTasks(); - } ); - - /** - * Pause processing when the tab/window becomes hidden. - */ - document.addEventListener( 'visibilitychange', () => { + function handleVisibilityChange() { if ( 'hidden' === document.visibilityState && isProcessing ) { isProcessing = false; isTabHidden = true; @@ -214,16 +202,28 @@ processTasks(); } } - } ); + } /** - * Prevent the user from leaving the page while processing. + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload event */ - window.addEventListener( 'beforeunload', function ( event ) { + function handleBeforeUnload( event ) { if ( isProcessing ) { event.preventDefault(); } - } ); + } + + // Attach event listeners. + + /** + * Primes the URL metrics for all breakpoints + * when the document is ready. + */ + document.addEventListener( 'DOMContentLoaded', processTasks ); + document.addEventListener( 'visibilitychange', handleVisibilityChange ); + window.addEventListener( 'beforeunload', handleBeforeUnload ); // @ts-ignore } )( odPrimeURLMetricsClassicEditor ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 1e933c47a2..8c94745826 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -66,7 +66,11 @@ let currentBatchNumber = 0; let isTabHidden = false; let abortController = new AbortController(); - const consoleLogPrefix = '[Optimization Detective Priming URL Metrics]'; + const consoleLogPrefix = '[Optimization Detective Priming Mode]'; + + // Create a ResizeObserver to fit the iframe when iframe resizes. + const iframeObserver = new ResizeObserver( fitIframe ); + iframeObserver.observe( iframe ); /** * Logs messages to the console. @@ -260,7 +264,7 @@ * @param {MessageEvent} event - The message event. * @return {Promise} The promise that resolves to void. */ - const handleMessage = async ( event ) => { + async function handleMessage( event ) { if ( event.data && event.data.type && @@ -278,39 +282,35 @@ ); } } - }; + } /** * Handles the aborting of the task on abort signal. * * @return {Promise} The promise that resolves to void. */ - const abortHandler = async () => { + async function abortHandler() { await cleanup(); reject( new Error( 'Task Aborted' ) ); - }; + } /** * Cleans up the event listeners and iframe. * * @return {Promise} The promise that resolves to void. */ - const cleanup = () => { + function cleanup() { return new Promise( ( cleanUpResolve ) => { signal.removeEventListener( 'abort', abortHandler ); window.removeEventListener( 'message', handleMessage ); clearTimeout( timeoutId ); iframe.onerror = null; iframe.src = 'about:blank'; - iframe.addEventListener( - 'load', - () => { - cleanUpResolve(); - }, - { once: true } - ); + iframe.addEventListener( 'load', () => cleanUpResolve(), { + once: true, + } ); } ); - }; + } const timeoutId = setTimeout( async () => { await cleanup(); @@ -339,38 +339,35 @@ iframe.src = url.toString(); iframe.width = task.width.toString(); iframe.height = task.height.toString(); + } ); + } - if ( isDebug ) { - /** - * Fits the iframe to the container. - */ - function fitIframe() { - const containerWidth = iframeContainer.clientWidth; - if ( containerWidth <= 0 ) { - return; - } + /** + * Fits the iframe to the container. + */ + function fitIframe() { + if ( ! isDebug ) { + return; + } + const containerWidth = iframeContainer.clientWidth; + if ( containerWidth <= 0 ) { + return; + } - const nativeWidth = parseInt( iframe.width, 10 ) || 1; - const scale = containerWidth / nativeWidth; + const nativeWidth = parseInt( iframe.width, 10 ) || 1; + const scale = containerWidth / nativeWidth; - iframe.style.position = 'unset'; - iframe.style.transform = `scale(${ scale })`; - iframe.style.pointerEvents = 'auto'; - iframe.style.opacity = '1'; - iframe.style.zIndex = '9999'; - } - window.addEventListener( 'resize', fitIframe ); - fitIframe(); - } - } ); + iframe.style.position = 'unset'; + iframe.style.transform = `scale(${ scale })`; + iframe.style.pointerEvents = 'auto'; + iframe.style.opacity = '1'; + iframe.style.zIndex = '9999'; } - controlButton.addEventListener( 'click', toggleProcessing ); - /** - * Pause processing when the tab/window becomes hidden, resume when visible. + * Handles visibility change events to pause/resume processing when tab/window visibility changes. */ - document.addEventListener( 'visibilitychange', () => { + function handleVisibilityChange() { if ( 'hidden' === document.visibilityState ) { if ( isProcessing ) { isProcessing = false; @@ -397,14 +394,22 @@ processBatches(); } } - } ); + } /** - * Prevent the user from leaving the page while processing. + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload event */ - window.addEventListener( 'beforeunload', function ( event ) { + function handleBeforeUnload( event ) { if ( isProcessing ) { event.preventDefault(); } - } ); + } + + // Attach event listeners. + controlButton.addEventListener( 'click', toggleProcessing ); + document.addEventListener( 'visibilitychange', handleVisibilityChange ); + window.addEventListener( 'beforeunload', handleBeforeUnload ); + window.addEventListener( 'resize', fitIframe ); } )(); diff --git a/plugins/optimization-detective/priming-cli/.gitignore b/plugins/optimization-detective/priming-cli/.gitignore deleted file mode 100644 index cea30382b2..0000000000 --- a/plugins/optimization-detective/priming-cli/.gitignore +++ /dev/null @@ -1,78 +0,0 @@ -############ -## Generated files during testing -############ - -plugins/*/tests/**/actual.html - -############ -## IDEs -############ - -*.pydevproject -.project -.metadata -*.swp -*~.nib -local.properties -.classpath -.settings/ -.loadpath -.externalToolBuilders/ -*.launch -.cproject -.buildpath -nbproject/ -.vscode - -############ -## Build -############ - -build -.wp-env.override.json -*.min.js -*.min.css -*.asset.php - -############ -## Vendor -############ - -node_modules/ -vendor/ - -############ -## OSes -############ - -[Tt]humbs.db -[Dd]esktop.ini -*.DS_store -.DS_store? - -############ -## Config overrides for CS tools -############ -phpcs.xml -phpunit.xml -phpstan.neon - -############ -## Misc -############ - -tests/logs -tmp/ -*.tmp -*.bak -*.log -*.[Cc]ache -*.cpr -*.orig -*.php.in -.idea/ -.sass-cache/* -temp/ -._* -.Trashes -.svn \ No newline at end of file From d6a07b1b784a4de45f48503e15806d397764e3f7 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 2 May 2025 23:16:45 +0530 Subject: [PATCH 51/62] Refactor URL metrics priming API and scripts for consistency and improved code quality - Rename endpoint classes and functions to consistently use "priming mode" terminology - Add TypeScript definitions for URL metrics priming features - Extract verification token logic into a dedicated function - Fix navigation issue in classic editor when using Update button - Improve JavaScript documentation with comprehensive JSDoc comments - Replace nested loops with flatMap for more concise code in JavaScript files - Use more descriptive variable names throughout the codebase - Add validation for cursor parameters to prevent potential issues - Reorganize code structure for better readability and maintenance --- plugins/optimization-detective/detection.php | 2 +- plugins/optimization-detective/helper.php | 90 +++++---- plugins/optimization-detective/load.php | 2 +- .../prime-url-metrics-block-editor.js | 110 +++++++--- .../prime-url-metrics-classic-editor.js | 86 +++++++- .../prime-url-metrics.js | 191 +++++++++++++----- .../priming-cli/index.js | 48 ++--- ...est-url-metrics-priming-mode-endpoint.php} | 15 +- .../storage/class-od-wp-cli.php | 2 +- plugins/optimization-detective/types.ts | 32 +++ 10 files changed, 425 insertions(+), 153 deletions(-) rename plugins/optimization-detective/storage/{class-od-rest-url-metrics-priming-endpoint.php => class-od-rest-url-metrics-priming-mode-endpoint.php} (88%) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index c4e00e6188..312be41085 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -243,7 +243,7 @@ function_exists( 'gzdecode' ) && * @access private */ function od_register_rest_url_metric_priming_endpoint(): void { - $endpoint_controller = new OD_REST_URL_Metrics_Priming_Endpoint(); + $endpoint_controller = new OD_REST_URL_Metrics_Priming_Mode_Endpoint(); register_rest_route( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, $endpoint_controller::PRIME_URLS_ROUTE, diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 19b58d76b1..0170e7e22a 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -215,7 +215,7 @@ function od_add_data_to_post_update_redirect_url_for_classic_editor( string $loc * @param array $cursor Cursor to resume from. * @return array Batch of URLs to prime metrics for and the updated cursor. */ -function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { +function od_get_batch_for_url_metrics_priming_mode( array $cursor ): array { // Get the server & its registry of sitemap providers. $server = wp_sitemaps_get_server(); $registry = $server->registry; @@ -231,23 +231,23 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { // Start iterating from the current provider_index forward. $providers_count = count( $providers ); - for ( $p = $cursor['provider_index']; $p < $providers_count && ! $done; ) { - $provider = $providers[ $p ]; + for ( $provider_index = $cursor['provider_index']; $provider_index < $providers_count && ! $done; ) { + $provider = $providers[ $provider_index ]; // WordPress providers return an array of strings from get_object_subtypes(). $subtypes = array_values( $provider->get_object_subtypes() ); // zero-based index. // Start from the current subtype_index if resuming. $subtypes_count = count( $subtypes ); - for ( $s = ( $p === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $s < $subtypes_count && ! $done; ) { + for ( $subtype_index = ( $provider_index === $cursor['provider_index'] ) ? $cursor['subtype_index'] : 0; $subtype_index < $subtypes_count && ! $done; ) { // This is a string, e.g. 'post', 'page', etc. - $subtype = $subtypes[ $s ]; + $subtype = $subtypes[ $subtype_index ]; // Retrieve the max number of pages for this subtype. $max_num_pages = $provider->get_max_num_pages( $subtype->name ); // Start from the current page_number if resuming. - for ( $page = ( ( $p === $cursor['provider_index'] ) && ( $s === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) { + for ( $page = ( ( $provider_index === $cursor['provider_index'] ) && ( $subtype_index === $cursor['subtype_index'] ) ) ? $cursor['page_number'] : 1; $page <= $max_num_pages && ! $done; ++$page ) { $url_list = $provider->get_url_list( $page, $subtype->name ); if ( ! is_array( $url_list ) ) { continue; @@ -292,7 +292,7 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { // We haven't fully finished this page, so keep the same $cursor['page_number']. $cursor['page_number'] = $page; } - } // end for pages + } // end for pages. if ( ! $done ) { // If we've finished all pages in this subtype, move to next subtype from the start (page 1, offset 0). @@ -300,9 +300,9 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { $cursor['offset_within_page'] = 0; } - $cursor['subtype_index'] = $s; - ++$s; - } // end for subtypes + $cursor['subtype_index'] = $subtype_index; + ++$subtype_index; + } // end for subtypes. if ( ! $done ) { // If we finished all subtypes in this provider, move to next provider and start at subtype=0, page=1. @@ -311,9 +311,9 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { $cursor['offset_within_page'] = 0; } - $cursor['provider_index'] = $p; - ++$p; - } // end for providers + $cursor['provider_index'] = $provider_index; + ++$provider_index; + } // end for providers. // Prepare next cursor. $new_cursor = array( @@ -333,6 +333,8 @@ function od_get_batch_for_iframe_url_metrics_priming( array $cursor ): array { /** * Filter for WP_Query to allow specifying 'post_title__in' => array( 'title1', 'title2', ... ). * + * This is needed because WP_Query does not support filtering by post_title. + * * @since n.e.x.t * @access private * @@ -363,6 +365,9 @@ function od_filter_posts_where_for_titles( string $where, WP_Query $query ): str /** * Fetches od_url_metrics posts of URLs in a single WP_Query. * + * This function is used to reduce the number of database queries done by querying all URLs in a + * single query instead of one per URL. + * * @since n.e.x.t * @access private * @@ -384,6 +389,7 @@ function od_get_metrics_by_post_title( array $urls ): array { 'post_type' => OD_URL_Metrics_Post_Type::SLUG, 'post_status' => 'publish', 'post_title__in' => $urls, + // Currently the count of urls is 10 or less for each batch so we can use -1 for now. 'posts_per_page' => -1, 'no_found_rows' => true, 'update_post_meta_cache' => false, @@ -453,17 +459,17 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { * @access private * * @param array $urls Array of URLs to filter. - * @return array}> Filtered batch of URLs. + * @return array}> Filtered batch of URL groups. */ -function od_filter_batch_urls_for_iframe_url_metrics_priming( array $urls ): array { - $filtered_batch = array(); +function od_filter_batch_urls_for_url_metrics_priming_mode( array $urls ): array { + $filtered_url_groups = array(); $standard_breakpoints = od_get_standard_breakpoints(); $group_collections = od_get_metrics_by_post_title( $urls ); foreach ( $urls as $url ) { $group_collection = $group_collections[ $url ] ?? null; if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { - $filtered_batch[] = array( + $filtered_url_groups[] = array( 'url' => $url, 'breakpoints' => $standard_breakpoints, ); @@ -491,21 +497,21 @@ function od_filter_batch_urls_for_iframe_url_metrics_priming( array $urls ): arr } if ( count( $missing_breakpoints ) > 0 ) { - $filtered_batch[] = array( + $filtered_url_groups[] = array( 'url' => $url, 'breakpoints' => $missing_breakpoints, ); } } - return $filtered_batch; + return $filtered_url_groups; } /** * Determines whether the admin-based URL priming feature should be displayed. * * Developers can force-enable the feature by filtering 'od_show_admin_url_priming_feature', or modify the - * threshold via 'od_admin_url_priming_threshold'. + * threshold via 'od_admin_url_priming_threshold' filter. * * @since n.e.x.t * @@ -566,7 +572,7 @@ function od_show_admin_url_priming_feature(): bool { } /** - * Generates the final batch of URLs for priming URL Metrics. + * Generates the batch of URLs for priming URL Metrics. * * @since n.e.x.t * @access private @@ -574,7 +580,7 @@ function od_show_admin_url_priming_feature(): bool { * @param array $cursor Cursor to resume from. * @return array Final batch of URLs to prime metrics for and the updated cursor. */ -function od_generate_final_batch_urls( array $cursor ): array { +function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array { $default_cursor = array( 'provider_index' => 0, 'subtype_index' => 0, @@ -583,28 +589,28 @@ function od_generate_final_batch_urls( array $cursor ): array { 'batch_size' => 10, ); - // Initialize cursor with default values. - $cursor = wp_parse_args( $cursor, $default_cursor ); + // Validate the cursor. + $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( (array) $cursor, $default_cursor ), $default_cursor ) ); if ( $default_cursor === $cursor ) { $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); if ( false !== $last_cursor ) { - $cursor = wp_parse_args( $last_cursor, $cursor ); + $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( $cursor, $last_cursor ), $last_cursor ) ); } } else { update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); } $batch = array(); - $filtered_batch_urls = array(); + $filtered_url_groups = array(); $prevent_infinite = 0; while ( $prevent_infinite < 100 ) { - if ( count( $filtered_batch_urls ) > 0 ) { + if ( count( $filtered_url_groups ) > 0 ) { break; } - $batch = od_get_batch_for_iframe_url_metrics_priming( $cursor ); - $filtered_batch_urls = od_filter_batch_urls_for_iframe_url_metrics_priming( $batch['urls'] ); + $batch = od_get_batch_for_url_metrics_priming_mode( $cursor ); + $filtered_url_groups = od_filter_batch_urls_for_url_metrics_priming_mode( $batch['urls'] ); if ( $cursor === $batch['cursor'] ) { delete_option( 'od_prime_url_metrics_batch_cursor' ); @@ -615,17 +621,27 @@ function od_generate_final_batch_urls( array $cursor ): array { ++$prevent_infinite; } - $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); + return array( + 'urlGroups' => $filtered_url_groups, + 'cursor' => $batch['cursor'], + 'verificationToken' => od_get_verification_token_for_priming_mode(), + 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + ); +} +/** + * Gets the verification token for priming mode. + * + * @since n.e.x.t + * @access private + * + * @return string Verification token. + */ +function od_get_verification_token_for_priming_mode(): string { + $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); if ( false === $verification_token ) { $verification_token = wp_generate_uuid4(); set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); } - - return array( - 'batch' => $filtered_batch_urls, - 'cursor' => $batch['cursor'], - 'verificationToken' => $verification_token, - 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, - ); + return $verification_token; } diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 46ac1653f8..42e8b92163 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -117,7 +117,7 @@ static function ( string $version ): void { require_once __DIR__ . '/storage/class-od-storage-lock.php'; require_once __DIR__ . '/storage/data.php'; require_once __DIR__ . '/storage/class-od-rest-url-metrics-store-endpoint.php'; - require_once __DIR__ . '/storage/class-od-rest-url-metrics-priming-endpoint.php'; + require_once __DIR__ . '/storage/class-od-rest-url-metrics-priming-mode-endpoint.php'; require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php'; // Detection logic. diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 44836ccc0a..449ac7c432 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -7,16 +7,74 @@ // @ts-ignore const { apiFetch } = wp; + /** + * Flag indicating whether URL priming is currently in progress. + * + * @type {boolean} + */ let isProcessing = false; + + /** + * Token used for verifying REST API requests server side. + * + * @type {string} + */ let verificationToken = ''; + + /** + * Array of breakpoint objects defining viewport dimensions. + * + * @type {import("./types.ts").ViewportBreakpoint[]} + */ let breakpoints = []; + + /** + * Queue of URL priming tasks generated from breakpoints. + * + * @type {import("./types.ts").URLPrimingTask[]} + */ let currentTasks = []; + + /** + * Index of the current task within currentTasks being processed. + * + * @type {number} + */ let currentTaskIndex = 0; + + /** + * Flag indicating whether the document tab/window is hidden. + * + * @type {boolean} + */ let isTabHidden = false; + + /** + * AbortController instance to support aborting ongoing task. + * + * @type {AbortController} + */ let abortController = new AbortController(); + + /** + * Promise tracking the processing of tasks to ensure sequential execution. + * + * @type {?Promise} + */ let processTasksPromise = null; + + /** + * Prefix which is prepended to messages logged to the console while in priming mode. + * + * @type {string} + */ const consoleLogPrefix = '[Optimization Detective Priming Mode]'; + /** + * Hidden iframe element used to load pages for metric priming. + * + * @type {HTMLIFrameElement} + */ const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; iframe.style.position = 'fixed'; @@ -74,7 +132,7 @@ ); } catch ( error ) { log( error.message ); - if ( 'Task Aborted' === error.message ) { + if ( abortController.signal.aborted ) { throw error; } } @@ -89,8 +147,8 @@ /** * Loads the iframe and waits for the message. * - * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. - * @param {AbortSignal} signal - The signal to abort the task. + * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ async function processTask( task, signal ) { @@ -179,29 +237,6 @@ } ); } - // Listen for post save/publish events. - let wasSaving = false; - subscribe( async () => { - const isSaving = select( 'core/editor' ).isSavingPost(); - const isAutosaving = select( 'core/editor' ).isAutosavingPost(); - - // Trigger when saving transitions from true to false (save completed). - if ( wasSaving && ! isSaving && ! isAutosaving ) { - wasSaving = false; - if ( processTasksPromise ) { - if ( ! abortController.signal.aborted ) { - abortController.abort(); - } - await processTasksPromise; - currentTaskIndex = 0; - abortController = new AbortController(); - } - processTasksPromise = processTasks(); - } else { - wasSaving = isSaving; - } - } ); - /** * Handles visibility change events to pause/resume processing when tab/window visibility changes. */ @@ -235,6 +270,29 @@ } } + // Listen for post save/publish events. + let wasSaving = false; + subscribe( async () => { + const isSaving = select( 'core/editor' ).isSavingPost(); + const isAutosaving = select( 'core/editor' ).isAutosavingPost(); + + // Trigger when saving transitions from true to false (save completed). + if ( wasSaving && ! isSaving && ! isAutosaving ) { + wasSaving = false; + if ( processTasksPromise ) { + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + await processTasksPromise; + currentTaskIndex = 0; + abortController = new AbortController(); + } + processTasksPromise = processTasks(); + } else { + wasSaving = isSaving; + } + } ); + // Attach event listeners. document.addEventListener( 'visibilitychange', handleVisibilityChange ); window.addEventListener( 'beforeunload', handleBeforeUnload ); diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index b57ce2b63f..2b9f84254b 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -1,5 +1,5 @@ /** - * Helper script for the Priming URL Metrics in block editor. + * Helper script for the Priming URL Metrics in classic editor. */ /* global odPrimeURLMetricsClassicEditor */ @@ -8,19 +8,92 @@ if ( 'undefined' === typeof odPrimeURLMetricsClassicEditor ) { return; } + + /** + * Permalink URL for the current post in classic editor. + * + * @type {string} + */ const permalink = odPrimeURLMetricsClassicEditor.permalink; + // @ts-ignore const { apiFetch } = wp; + /** + * Flag indicating whether URL priming is currently in progress. + * + * @type {boolean} + */ let isProcessing = false; + + /** + * Token used for verifying REST API requests server side. + * + * @type {string} + */ let verificationToken = ''; + + /** + * Array of viewport breakpoint objects defining dimensions. + * + * @type {import("./types.ts").ViewportBreakpoint[]} + */ let breakpoints = []; + + /** + * Queue of URL priming tasks generated from breakpoints. + * + * @type {import("./types.ts").URLPrimingTask[]} + */ let currentTasks = []; + + /** + * Index of the current task within currentTasks being processed. + * + * @type {number} + */ let currentTaskIndex = 0; + + /** + * Flag indicating whether the document tab/window is hidden. + * + * @type {boolean} + */ let isTabHidden = false; + + /** + * AbortController instance to support aborting ongoing task. + * + * @type {AbortController} + */ let abortController = new AbortController(); + + /** + * Prefix which is prepended to messages logged to the console while in priming mode. + * + * @type {string} + */ const consoleLogPrefix = '[Optimization Detective Priming Mode]'; + /** + * Button element for publishing, allowing page leave after click. + * + * @type {HTMLInputElement} + */ + const updateButton = document.querySelector( 'input#publish' ); + + /** + * Flag that indicates if navigation away from page is allowed. + * + * @type {boolean} + */ + let allowLeavingPage = false; + + /** + * Hidden iframe element used to load pages for metric priming. + * + * @type {HTMLIFrameElement} + */ const iframe = document.createElement( 'iframe' ); iframe.id = 'od-prime-url-metrics-iframe'; iframe.style.position = 'fixed'; @@ -77,7 +150,7 @@ ); } catch ( error ) { log( error ); - if ( 'Task Aborted' === error.message ) { + if ( abortController.signal.aborted ) { throw error; } } @@ -92,8 +165,8 @@ /** * Loads the iframe and waits for the message. * - * @param {{url: string, width: number, height: number}} task - The breakpoint to set for the iframe. - * @param {AbortSignal} signal - The signal to abort the task. + * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ function processTask( task, signal ) { @@ -210,7 +283,7 @@ * @param {BeforeUnloadEvent} event - The beforeunload event */ function handleBeforeUnload( event ) { - if ( isProcessing ) { + if ( isProcessing && ! allowLeavingPage ) { event.preventDefault(); } } @@ -224,6 +297,9 @@ document.addEventListener( 'DOMContentLoaded', processTasks ); document.addEventListener( 'visibilitychange', handleVisibilityChange ); window.addEventListener( 'beforeunload', handleBeforeUnload ); + updateButton.addEventListener( 'click', () => { + allowLeavingPage = true; + } ); // @ts-ignore } )( odPrimeURLMetricsClassicEditor ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 8c94745826..399b7375c5 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -6,37 +6,65 @@ const { i18n, apiFetch } = wp; const { __ } = i18n; - /** @type {HTMLButtonElement} */ + /** + * Button element that toggles processing state. + * + * @type {HTMLButtonElement} + */ const controlButton = document.querySelector( 'button#od-prime-url-metrics-control-button' ); - /** @type {HTMLProgressElement} */ + /** + * Progress bar element displaying current task completion progress. + * + * @type {HTMLProgressElement} + */ const progressBar = document.querySelector( 'progress#od-prime-url-metrics-progress' ); - /** @type {HTMLIFrameElement} */ + /** + * Iframe used to load pages for priming URL metrics. + * + * @type {HTMLIFrameElement} + */ const iframe = document.querySelector( 'iframe#od-prime-url-metrics-iframe' ); - /** @type {HTMLDivElement} */ + /** + * Container that holds the iframe. + * + * @type {HTMLDivElement} + */ const iframeContainer = document.querySelector( 'div#od-prime-url-metrics-iframe-container' ); - /** @type {HTMLSpanElement} */ + /** + * Element that displays the current batch number being processed. + * + * @type {HTMLSpanElement} + */ const currentBatchElement = document.querySelector( 'span#od-prime-url-metrics-current-batch' ); - /** @type {HTMLSpanElement} */ + /** + * Element that displays the current task number being processed. + * + * @type {HTMLSpanElement} + */ const currentTaskElement = document.querySelector( 'span#od-prime-url-metrics-current-task' ); - /** @type {HTMLSpanElement} */ + /** + * Element that displays the total number of tasks in the current batch. + * + * @type {HTMLSpanElement} + */ const totalTasksInBatchElement = document.querySelector( 'span#od-prime-url-metrics-total-tasks-in-batch' ); @@ -54,21 +82,95 @@ return; } - // Initialize state variables. + /** + * Flag indicating whether priming is currently in progress. + * + * @type {boolean} + */ let isProcessing = false; + + /** + * Indicates whether more batches are available for processing. + * + * @type {boolean} + */ let isNextBatchAvailable = true; - let cursor = {}; + + /** + * Pagination cursor for retrieving the next batch of URLs. + * + * @type {?import("./types.ts").URLBatchCursor} + */ + let cursor = null; + + /** + * Flag indicating if debug mode is enabled. + * + * @type {boolean} + */ let isDebug = false; + + /** + * Token used for verifying REST API requests server side. + * + * @type {string} + */ let verificationToken = ''; + + /** + * Currently active batch of data from the REST API. + * + * @type {?import("./types.ts").URLBatchResponse} + */ let currentBatch = null; + + /** + * Array of URL priming tasks extracted from the current batch. + * + * @type {import("./types.ts").URLPrimingTask[]} + */ let currentTasks = []; + + /** + * Index of the currently executing task in the batch. + * + * @type {number} + */ let currentTaskIndex = 0; + + /** + * Running count of how many batches have been processed. + * + * @type {number} + */ let currentBatchNumber = 0; + + /** + * Flag indicating whether the tab/window is hidden. + * + * @type {boolean} + */ let isTabHidden = false; + + /** + * AbortController instance to support aborting ongoing task. + * + * @type {AbortController} + */ let abortController = new AbortController(); + + /** + * Prefix which is prepended to messages logged to the console while in priming mode. + * + * @type {string} + */ const consoleLogPrefix = '[Optimization Detective Priming Mode]'; - // Create a ResizeObserver to fit the iframe when iframe resizes. + /** + * ResizeObserver instance that adjusts iframe scale within container. + * + * @type {ResizeObserver} + */ const iframeObserver = new ResizeObserver( fitIframe ); iframeObserver.observe( iframe ); @@ -83,11 +185,11 @@ } /** - * Toggles the processing state. + * Toggles the processing state of the priming task. */ function toggleProcessing() { if ( isProcessing ) { - // Pause processing + // Pause processing. isProcessing = false; controlButton.textContent = __( 'Resume', @@ -98,7 +200,7 @@ abortController.abort(); } } else { - // Start/resume processing + // Start/resume processing. isProcessing = true; controlButton.textContent = __( 'Pause', 'optimization-detective' ); controlButton.classList.add( 'updating-message' ); @@ -110,7 +212,7 @@ } /** - * Main processing controller function. + * Processes batches of URL priming tasks. */ async function processBatches() { try { @@ -135,7 +237,7 @@ } } } catch ( error ) { - if ( ! isTabHidden && 'Task Aborted' !== error.message ) { + if ( ! isTabHidden && ! abortController.signal.aborted ) { isProcessing = false; controlButton.textContent = __( 'Click to retry', @@ -169,7 +271,7 @@ ); currentBatch = await getBatch( cursor ); - if ( ! currentBatch.batch.length ) { + if ( ! currentBatch.urlGroups.length ) { isNextBatchAvailable = false; return; } @@ -180,14 +282,14 @@ // Initialize batch state. verificationToken = currentBatch.verificationToken; isDebug = currentBatch.isDebug; - currentTasks = flattenBatchToTasks( currentBatch ); + currentTasks = flattenBatchToTasks( currentBatch.urlGroups ); currentTaskIndex = 0; // Update UI for new batch. progressBar.max = currentTasks.length; - progressBar.value = 0; + progressBar.value = currentTaskIndex + 1; totalTasksInBatchElement.textContent = currentTasks.length.toString(); - currentTaskElement.textContent = '0'; + currentTaskElement.textContent = ( currentTaskIndex + 1 ).toString(); controlButton.textContent = __( 'Pause', 'optimization-detective' ); } @@ -203,57 +305,54 @@ ); } catch ( error ) { log( error.message ); - if ( 'Task Aborted' === error.message ) { + if ( abortController.signal.aborted ) { throw error; } - } finally { - currentTaskIndex++; - progressBar.value = currentTaskIndex; - currentTaskElement.textContent = currentTaskIndex.toString(); } + currentTaskIndex++; + progressBar.value = currentTaskIndex + 1; + currentTaskElement.textContent = ( + currentTaskIndex + 1 + ).toString(); } } /** - * Flattens the batch to tasks. + * Flattens the url groups to tasks. * - * @param {Object} batch - The batch to flatten. - * @return {Array<{ url: string, width: number, height: number }>} - The flattened tasks. + * @param {import("./types.ts").URLGroup[]} urlGroups - The url groups to flatten. + * @return {import("./types.ts").URLPrimingTask[]} - The flattened tasks. */ - function flattenBatchToTasks( batch ) { - const tasks = []; - for ( const url of batch.batch ) { - for ( const breakpoint of url.breakpoints ) { - tasks.push( { - url: url.url, - width: breakpoint.width, - height: breakpoint.height, - } ); - } - } - return tasks; + function flattenBatchToTasks( urlGroups ) { + return urlGroups.flatMap( ( urlGroup ) => + urlGroup.breakpoints.map( ( breakpoint ) => ( { + url: urlGroup.url, + width: breakpoint.width, + height: breakpoint.height, + } ) ) + ); } /** - * Fetches the next batch of URLs. + * Fetches the next batch of URLs for metric priming. * - * @param {Object} lastCursor - The cursor to fetch the next batch. - * @return {Promise} - The promise that resolves to the batch of URLs. + * @param {?import("./types.ts").URLBatchCursor} lastCursor - The pagination cursor from the last batch or null for the first batch. + * @return {Promise} - Resolves with the next batch of URLs and metadata. */ + async function getBatch( lastCursor ) { - const response = await apiFetch( { + return await apiFetch( { path: '/optimization-detective/v1/prime-urls', method: 'POST', data: { cursor: lastCursor }, } ); - return response; } /** * Loads the iframe and waits for the message. * - * @param {{ url: string, width: number, height: number }} task - The breakpoint to set for the iframe. - * @param {AbortSignal} signal - The signal to abort the task. + * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ function processTask( task, signal ) { diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index d924cc3a0c..2412fdaf0c 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -88,8 +88,8 @@ function checkEnvironment() { /** * Fetches the next batch of URLs. * - * @param {Object} lastCursor - The cursor to fetch the next batch. - * @return {?Object} - The batch of URLs. + * @param {import("../types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch. + * @return {?import("../types.ts").URLBatchResponse} - The batch of URLs. */ function getBatch( lastCursor ) { try { @@ -112,32 +112,28 @@ function getBatch( lastCursor ) { } /** - * Flattens the batch into individual tasks. + * Flattens the url groups to tasks. * - * @param {Object} batch - The batch to flatten. - * @return {Array<{ url: string, width: number, height: number }>} The list of tasks. + * @param {import("../types.ts").URLGroup[]} urlGroups - The url groups to flatten. + * @return {import("../types.ts").URLPrimingTask[]} - The flattened tasks. */ -function flattenBatchToTasks( batch ) { - const tasks = []; - for ( const urlObj of batch.batch ) { - for ( const breakpoint of urlObj.breakpoints ) { - tasks.push( { - url: urlObj.url, - width: breakpoint.width, - height: breakpoint.height, - } ); - } - } - return tasks; +function flattenBatchToTasks( urlGroups ) { + return urlGroups.flatMap( ( urlGroup ) => + urlGroup.breakpoints.map( ( breakpoint ) => ( { + url: urlGroup.url, + width: breakpoint.width, + height: breakpoint.height, + } ) ) + ); } /** * Processes a single task using Puppeteer. * - * @param {Page} page - The Puppeteer page to use. - * @param {{ url: string, width: number, height: number }} task - The task parameters. - * @param {string} verificationToken - The verification token. - * @param {AbortSignal} abortSignal - The abort signal. + * @param {Page} page - The Puppeteer page to use. + * @param {import("../types.ts").URLPrimingTask} task - The task parameters. + * @param {string} verificationToken - The verification token. + * @param {AbortSignal} abortSignal - The abort signal. * @return {Promise} */ async function processTask( page, task, verificationToken, abortSignal ) { @@ -269,9 +265,9 @@ async function init() { /** * Cursor object to track position in pagination when fetching URL batches. * - * @type {Object} + * @type {import("../types.ts").URLBatchCursor} */ - let cursor = {}; + let cursor = null; /** * Counter tracking the number of URL batches processed so far. @@ -298,8 +294,8 @@ async function init() { // If no URLs remain in the batch, finish processing. if ( null === currentBatch || - ! currentBatch.batch || - currentBatch.batch.length === 0 + ! currentBatch.urlGroups || + currentBatch.urlGroups.length === 0 ) { isNextBatchAvailable = false; break; @@ -309,7 +305,7 @@ async function init() { spinner.text = `Batch ${ currentBatchNumber } fetched successfully.`; - const currentTasks = flattenBatchToTasks( currentBatch ); + const currentTasks = flattenBatchToTasks( currentBatch.urlGroups ); // Process each task sequentially. for ( let i = 0; i < currentTasks.length; i++ ) { diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php similarity index 88% rename from plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php rename to plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php index aacbe22088..722256e74c 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php @@ -1,6 +1,6 @@ get_param( 'cursor' ); - return new WP_REST_Response( od_generate_final_batch_urls( $cursor ) ); + return new WP_REST_Response( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ); } /** @@ -157,11 +157,6 @@ public function handle_generate_breakpoints_request(): WP_REST_Response { * @return WP_REST_Response Response. */ public function handle_get_verification_token_request(): WP_REST_Response { - $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); - if ( false === $verification_token ) { - $verification_token = wp_generate_uuid4(); - set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); - } - return new WP_REST_Response( $verification_token ); + return new WP_REST_Response( od_get_verification_token_for_priming_mode() ); } } diff --git a/plugins/optimization-detective/storage/class-od-wp-cli.php b/plugins/optimization-detective/storage/class-od-wp-cli.php index 62552c339c..1dfc0c234d 100644 --- a/plugins/optimization-detective/storage/class-od-wp-cli.php +++ b/plugins/optimization-detective/storage/class-od-wp-cli.php @@ -61,7 +61,7 @@ public function get_url_batch( array $args, array $assoc_args ): void { $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; if ( function_exists( '\\WP_CLI\\Utils\\format_items' ) ) { - WP_CLI\Utils\format_items( $format, array( od_generate_final_batch_urls( $cursor ) ), array( 'batch', 'cursor', 'verificationToken', 'isDebug' ) ); + WP_CLI\Utils\format_items( $format, array( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ), array( 'batch', 'cursor', 'verificationToken', 'isDebug' ) ); } } } diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index 39533aed39..555a6f1254 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -104,3 +104,35 @@ export interface Extension { readonly initialize?: InitializeCallback; readonly finalize?: FinalizeCallback; } + +// Below are the types for the URL Metric priming scripts. +export interface URLBatchCursor { + provider_index: number; + subtype_index: number; + page_number: number; + offset_within_page: number; + batch_size: number; +} + +export interface ViewportBreakpoint { + width: number; + height: number; +} + +export interface URLGroup { + url: string; + breakpoints: ViewportBreakpoint[]; +} + +export interface URLBatchResponse { + urlGroups: URLGroup[]; + cursor: URLBatchCursor | null; + verificationToken: string; + isDebug: boolean; +} + +export interface URLPrimingTask { + url: string; + width: number; + height: number; +} From b201dbe0e729ce55601f3e265e171c744ce0c21f Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Sat, 3 May 2025 00:08:50 +0530 Subject: [PATCH 52/62] Improve URL metrics priming UI and CLI parameter handling --- .../prime-url-metrics.js | 15 ++++++ .../priming-cli/index.js | 26 +++++++--- plugins/optimization-detective/settings.php | 10 ++-- .../storage/class-od-wp-cli.php | 50 ++++++++++++++----- 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 399b7375c5..6f02f39c91 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -24,6 +24,15 @@ 'progress#od-prime-url-metrics-progress' ); + /** + * Container element displaying the status of URL metrics priming. + * + * @type {HTMLDivElement} + */ + const statusContainer = document.querySelector( + 'div#od-prime-url-metrics-status-container' + ); + /** * Iframe used to load pages for priming URL metrics. * @@ -73,6 +82,7 @@ if ( ! controlButton || ! progressBar || + ! statusContainer || ! iframe || ! iframeContainer || ! currentBatchElement || @@ -207,6 +217,7 @@ if ( abortController.signal.aborted ) { abortController = new AbortController(); } + statusContainer.style.display = 'block'; processBatches(); } } @@ -256,7 +267,11 @@ iframe.src = 'about:blank'; iframe.width = '0'; iframe.height = '0'; + progressBar.value = 0; currentBatchElement.textContent = '0'; + currentTaskElement.textContent = '0'; + totalTasksInBatchElement.textContent = '0'; + statusContainer.style.display = 'none'; } } } diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 2412fdaf0c..324aff64fa 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -88,22 +88,30 @@ function checkEnvironment() { /** * Fetches the next batch of URLs. * - * @param {import("../types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch. + * @param {?import("../types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch. * @return {?import("../types.ts").URLBatchResponse} - The batch of URLs. */ function getBatch( lastCursor ) { try { - const batchOutput = execSync( - `wp od get_url_batch --format=json --cursor='${ JSON.stringify( - lastCursor - ) }'` - ).toString(); + let command = 'wp od get_url_batch --format=json'; + + if ( lastCursor ) { + command += ` --provider-index=${ lastCursor.provider_index || 0 }`; + command += ` --subtype-index=${ lastCursor.subtype_index || 0 }`; + command += ` --page-number=${ lastCursor.page_number || 0 }`; + command += ` --offset-within-page=${ + lastCursor.offset_within_page || 0 + }`; + command += ` --batch-size=${ lastCursor.batch_size || 10 }`; + } + + const batchOutput = execSync( command ).toString(); const parsedBatch = JSON.parse( batchOutput ); if ( ! parsedBatch || parsedBatch.length === 0 ) { throw new Error( 'Invalid batch data received.' ); } - return JSON.parse( batchOutput )[ 0 ]; + return parsedBatch[ 0 ]; } catch ( error ) { spinner.fail( 'Error occurred while fetching batch: ' + error.message ); abortController.abort(); @@ -315,7 +323,9 @@ async function init() { const task = currentTasks[ i ]; spinner.start( - `Processing task ${ chalk.green( + `Processing batch ${ chalk.green( + currentBatchNumber + ) } task ${ chalk.green( i + 1 + '/' + currentTasks.length ) } for ${ chalk.blue( task.url ) } at ${ chalk.blue( task.width + 'x' + task.height diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index a53791220e..8bdbc976b9 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -62,10 +62,12 @@ function od_render_optimization_detective_page(): void {
- -
- 0 - 0 / 0 +
diff --git a/plugins/optimization-detective/storage/class-od-wp-cli.php b/plugins/optimization-detective/storage/class-od-wp-cli.php index 1dfc0c234d..79ea6e3a2e 100644 --- a/plugins/optimization-detective/storage/class-od-wp-cli.php +++ b/plugins/optimization-detective/storage/class-od-wp-cli.php @@ -26,10 +26,34 @@ class OD_WP_CLI { * * ## OPTIONS * - * [--cursor=] - * : JSON encoded cursor to paginate through the URLs. + * [--provider-index=] + * : Index of the provider. * --- - * default: [] + * default: 0 + * --- + * + * [--subtype-index=] + * : Index of the subtype. + * --- + * default: 0 + * --- + * + * [--page-number=] + * : Page number for pagination. + * --- + * default: 0 + * --- + * + * [--offset-within-page=] + * : Offset within the current page. + * --- + * default: 0 + * --- + * + * [--batch-size=] + * : Number of items to return. + * --- + * default: 10 * --- * * [--format=] @@ -43,25 +67,25 @@ class OD_WP_CLI { * # Get a batch of URLs that need to be primed * $ wp od get_url_batch --format=json * - * # List 20 URL metrics in JSON format - * $ wp od get_url_batch --cursor='{"provider_index":0,"subtype_index":0,"page_number":1,"offset_within_page":0,"batch_size":10}' --format=json + * # List 20 URL metrics with specific pagination parameters + * $ wp od get_url_batch --provider-index=0 --subtype-index=0 --page-number=1 --offset-within-page=0 --batch-size=20 --format=json * * @param array $args Command arguments. * @param array $assoc_args Command associated arguments. */ public function get_url_batch( array $args, array $assoc_args ): void { - $cursor = array(); - if ( isset( $assoc_args['cursor'] ) ) { - $cursor = json_decode( $assoc_args['cursor'], true ); + $cursor = array( + 'provider_index' => isset( $assoc_args['provider-index'] ) ? (int) $assoc_args['provider-index'] : 0, + 'subtype_index' => isset( $assoc_args['subtype-index'] ) ? (int) $assoc_args['subtype-index'] : 0, + 'page_number' => isset( $assoc_args['page-number'] ) ? (int) $assoc_args['page-number'] : 0, + 'offset_within_page' => isset( $assoc_args['offset-within-page'] ) ? (int) $assoc_args['offset-within-page'] : 0, + 'batch_size' => isset( $assoc_args['batch-size'] ) ? (int) $assoc_args['batch-size'] : 10, + ); - if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $cursor ) ) { - $cursor = array(); - } - } $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; if ( function_exists( '\\WP_CLI\\Utils\\format_items' ) ) { - WP_CLI\Utils\format_items( $format, array( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ), array( 'batch', 'cursor', 'verificationToken', 'isDebug' ) ); + WP_CLI\Utils\format_items( $format, array( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ), array( 'urlGroups', 'cursor', 'verificationToken', 'isDebug' ) ); } } } From ab9ce29259d6e58066521bdf0b0bce21e7c4bafb Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 31 Jul 2025 14:46:40 +0530 Subject: [PATCH 53/62] Add missing parameter type --- plugins/optimization-detective/helper.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 9afbaa138a..9307d675f5 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -694,7 +694,7 @@ function od_show_admin_url_priming_feature(): bool { * @since n.e.x.t * @access private * - * @param array $cursor Cursor to resume from. + * @param array|null $cursor Cursor to resume from. * @return array Final batch of URLs to prime metrics for and the updated cursor. */ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array { @@ -718,10 +718,10 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); } - $batch = array(); - $filtered_url_groups = array(); - $prevent_infinite = 0; - while ( $prevent_infinite < 100 ) { + $batch = array(); + $filtered_url_groups = array(); + $prevent_infinite_loop = 0; + while ( $prevent_infinite_loop < 100 ) { if ( count( $filtered_url_groups ) > 0 ) { break; } @@ -735,7 +735,7 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array } $cursor = $batch['cursor']; - ++$prevent_infinite; + ++$prevent_infinite_loop; } return array( From 26dda1d85789c4d041e8c5fe008e85dbbcdd9115 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 31 Jul 2025 14:49:25 +0530 Subject: [PATCH 54/62] Add TypeScript types for Priming CLI --- .../priming-cli/index.js | 20 ++++++------- .../priming-cli/types.ts | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 plugins/optimization-detective/priming-cli/types.ts diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 324aff64fa..1335b9de1a 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -88,8 +88,8 @@ function checkEnvironment() { /** * Fetches the next batch of URLs. * - * @param {?import("../types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch. - * @return {?import("../types.ts").URLBatchResponse} - The batch of URLs. + * @param {?import("./types.ts").URLBatchCursor} lastCursor - The cursor to fetch the next batch. + * @return {?import("./types.ts").URLBatchResponse} - The batch of URLs. */ function getBatch( lastCursor ) { try { @@ -122,8 +122,8 @@ function getBatch( lastCursor ) { /** * Flattens the url groups to tasks. * - * @param {import("../types.ts").URLGroup[]} urlGroups - The url groups to flatten. - * @return {import("../types.ts").URLPrimingTask[]} - The flattened tasks. + * @param {import("./types.ts").URLGroup[]} urlGroups - The url groups to flatten. + * @return {import("./types.ts").URLPrimingTask[]} - The flattened tasks. */ function flattenBatchToTasks( urlGroups ) { return urlGroups.flatMap( ( urlGroup ) => @@ -138,10 +138,10 @@ function flattenBatchToTasks( urlGroups ) { /** * Processes a single task using Puppeteer. * - * @param {Page} page - The Puppeteer page to use. - * @param {import("../types.ts").URLPrimingTask} task - The task parameters. - * @param {string} verificationToken - The verification token. - * @param {AbortSignal} abortSignal - The abort signal. + * @param {Page} page - The Puppeteer page to use. + * @param {import("./types.ts").URLPrimingTask} task - The task parameters. + * @param {string} verificationToken - The verification token. + * @param {AbortSignal} abortSignal - The abort signal. * @return {Promise} */ async function processTask( page, task, verificationToken, abortSignal ) { @@ -273,7 +273,7 @@ async function init() { /** * Cursor object to track position in pagination when fetching URL batches. * - * @type {import("../types.ts").URLBatchCursor} + * @type {import("./types.ts").URLBatchCursor} */ let cursor = null; @@ -297,7 +297,7 @@ async function init() { break; } spinner.start( 'Fetching next batch' ); - const currentBatch = await getBatch( cursor ); + const currentBatch = getBatch( cursor ); // If no URLs remain in the batch, finish processing. if ( diff --git a/plugins/optimization-detective/priming-cli/types.ts b/plugins/optimization-detective/priming-cli/types.ts new file mode 100644 index 0000000000..f57e91dba6 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/types.ts @@ -0,0 +1,30 @@ +export interface URLBatchCursor { + provider_index: number; + subtype_index: number; + page_number: number; + offset_within_page: number; + batch_size: number; +} + +export interface ViewportBreakpoint { + width: number; + height: number; +} + +export interface URLGroup { + url: string; + breakpoints: ViewportBreakpoint[]; +} + +export interface URLBatchResponse { + urlGroups: URLGroup[]; + cursor: URLBatchCursor | null; + verificationToken: string; + isDebug: boolean; +} + +export interface URLPrimingTask { + url: string; + width: number; + height: number; +} From aa7391b22e0bb558e8f890c7f567b56f69de186f Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 31 Jul 2025 18:11:33 +0530 Subject: [PATCH 55/62] Add priming mode source tracking --- plugins/optimization-detective/detect.js | 65 +++++++++++-------- .../prime-url-metrics-block-editor.js | 4 +- .../prime-url-metrics-classic-editor.js | 4 +- .../prime-url-metrics.js | 4 +- .../priming-cli/index.js | 4 +- plugins/optimization-detective/settings.php | 2 +- 6 files changed, 48 insertions(+), 35 deletions(-) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 35e1d78b45..3ad8899da1 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -66,12 +66,20 @@ const storageLockTimeSessionKey = 'odStorageLockTime'; const compressionDebounceWaitDuration = 1000; /** - * Verification token for skipping the storage lock check while priming URL Metrics. + * Verification token for skipping the storage lock check while in priming mode. * * @see {detect} * @type {?string} */ -let odPrimeUrlMetricsVerificationToken = null; +let primeModeVerificationToken = null; + +/** + * Source of the priming mode (i.e. admin-dashboard or priming-cli). + * + * @see {detect} + * @type {?string} + */ +let primeModeSource = null; /** * Checks whether storage is locked. @@ -81,7 +89,7 @@ let odPrimeUrlMetricsVerificationToken = null; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { - if ( odPrimeUrlMetricsVerificationToken ) { + if ( primeModeVerificationToken ) { return false; } @@ -551,27 +559,25 @@ async function forceCompressUrlMetric() { /** * Notifies about the URL Metric request status. * - * @param {Object} status - The status details. - * @param {boolean} status.success - Indicates if the request succeeded. - * @param {string} [status.error] - An error message if the request failed. - * @param {Object} [options] - Options for where to dispatch the message. - * @param {boolean} [options.toParent=true] - Whether to send the message to the parent window. - * @param {boolean} [options.toLocal=true] - Whether to dispatch a custom event locally. + * @param {Object} status - The status details. + * @param {boolean} status.success - Indicates if the request succeeded. + * @param {string} [status.error] - An error message if the request failed. + * @param {?string} source - The source of the priming mode (i.e. admin-dashboard or priming-cli). */ -function notifyStatus( status, options = { toParent: true, toLocal: true } ) { +function notifyStatus( status, source ) { const message = { type: 'OD_PRIME_URL_METRICS_REQUEST_STATUS', success: status.success, ...( status.error && { error: status.error } ), }; - // This will be used when URL metrics are primed using a IFRAME. - if ( options.toParent && window.parent && window.parent !== window ) { + if ( + 'admin-dashboard' === source && + window.parent && + window.parent !== window + ) { window.parent.postMessage( message, '*' ); - } - - // This will be used when URL metrics are primed using Puppeteer script. - if ( options.toLocal ) { + } else if ( 'priming-cli' === source ) { document.dispatchEvent( new CustomEvent( message.type, { detail: { ...status }, @@ -721,9 +727,13 @@ export default async function detect( { // Presence of the token indicates that the URL Metric is being primed // through the Puppeteer script or WordPress admin dashboard. if ( '' !== window.location.hash ) { - odPrimeUrlMetricsVerificationToken = new URLSearchParams( + const searchParams = new URLSearchParams( window.location.hash.slice( 1 ) - ).get( 'odPrimeUrlMetricsVerificationToken' ); + ); + primeModeVerificationToken = searchParams.get( + 'odPrimeModeVerificationToken' + ); + primeModeSource = searchParams.get( 'odPrimeModeSource' ); } // Abort if the client already submitted a URL Metric for this URL and viewport group. @@ -735,7 +745,7 @@ export default async function detect( { logger ); if ( - ! odPrimeUrlMetricsVerificationToken && + ! primeModeVerificationToken && null !== alreadySubmittedSessionStorageKey && alreadySubmittedSessionStorageKey in sessionStorage ) { @@ -1054,7 +1064,7 @@ export default async function detect( { // Wait for the page to be hidden. await new Promise( async ( resolve ) => { - if ( ! odPrimeUrlMetricsVerificationToken ) { + if ( ! primeModeVerificationToken ) { win.addEventListener( 'pagehide', resolve, { once: true } ); win.addEventListener( 'pageswap', resolve, { once: true } ); doc.addEventListener( @@ -1230,10 +1240,10 @@ export default async function detect( { ); } url.searchParams.set( 'hmac', urlMetricHMAC ); - if ( odPrimeUrlMetricsVerificationToken ) { + if ( primeModeVerificationToken ) { url.searchParams.set( 'prime_url_metrics_verification_token', - odPrimeUrlMetricsVerificationToken + primeModeVerificationToken ); } @@ -1248,10 +1258,10 @@ export default async function detect( { method: 'POST', body: payloadBlob, headers, - keepalive: odPrimeUrlMetricsVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon(). + keepalive: primeModeVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon(). } ); - if ( ! odPrimeUrlMetricsVerificationToken ) { + if ( ! primeModeVerificationToken ) { await fetch( request ); } else { try { @@ -1261,9 +1271,12 @@ export default async function detect( { `Failed to send URL Metric. Status: ${ response.status }` ); } - notifyStatus( { success: true } ); + notifyStatus( { success: true }, primeModeSource ); } catch ( err ) { - notifyStatus( { success: false, error: err.message } ); + notifyStatus( + { success: false, error: err.message }, + primeModeSource + ); } } } diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 449ac7c432..b58d1cff09 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -226,9 +226,9 @@ }; const url = new URL( task.url ); - url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( verificationToken - ) }`; + ) }&odPrimeModeSource=admin-dashboard`; // Load the iframe. iframe.src = url.toString(); diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 2b9f84254b..9008acff89 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -244,9 +244,9 @@ }; const url = new URL( task.url ); - url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( verificationToken - ) }`; + ) }&odPrimeModeSource=admin-dashboard`; // Load the iframe. iframe.src = url.toString(); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 6f02f39c91..84555fd8a5 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -445,9 +445,9 @@ }; const url = new URL( task.url ); - url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( verificationToken - ) }`; + ) }&odPrimeModeSource=admin-dashboard`; // Load the iframe. iframe.src = url.toString(); diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 1335b9de1a..ab40d38b42 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -184,9 +184,9 @@ async function processTask( page, task, verificationToken, abortSignal ) { } ); const url = new URL( task.url ); - url.hash = `odPrimeUrlMetricsVerificationToken=${ encodeURIComponent( + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( verificationToken - ) }`; + ) }&odPrimeModeSource=priming-cli`; await page.goto( url.toString(), { waitUntil: 'load', diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index 8bdbc976b9..ffc28061d0 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -50,7 +50,7 @@ function od_render_optimization_detective_page(): void {
  1. -
  2. +

From 5335d2aee5204dce7ae3cdfe4ad5fadee7d1a51f Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Thu, 31 Jul 2025 18:34:52 +0530 Subject: [PATCH 56/62] Fix missing sanitation of nonce for classic editor post update --- plugins/optimization-detective/helper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 9307d675f5..5dce61c4f6 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -264,7 +264,7 @@ function od_enqueue_prime_url_metrics_scripts( string $hook_suffix ): void { 'post.php' === $hook_suffix && function_exists( 'get_current_screen' ) && isset( $_GET['od_classic_editor_post_update_nonce'] ) && - false !== wp_verify_nonce( $_GET['od_classic_editor_post_update_nonce'], 'od_classic_editor_post_update' ) && + false !== wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['od_classic_editor_post_update_nonce'] ) ), 'od_classic_editor_post_update' ) && isset( $_GET['post'] ) && isset( $_GET['message'] ) && 1 === (int) $_GET['message'] From c934b867b18ade716bd0fd6569ea997c6cc67d22 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 29 Aug 2025 17:13:29 +0530 Subject: [PATCH 57/62] Refactor URL metrics priming functions and variables for consistency and clarity --- plugins/optimization-detective/helper.php | 41 +++++++++---------- .../prime-url-metrics-block-editor.js | 6 +-- .../prime-url-metrics-classic-editor.js | 6 +-- .../prime-url-metrics.js | 18 ++++---- .../priming-cli/index.js | 4 +- plugins/optimization-detective/settings.php | 18 ++++---- ...rest-url-metrics-priming-mode-endpoint.php | 10 ++--- ...ass-od-rest-url-metrics-store-endpoint.php | 2 +- .../storage/class-od-wp-cli.php | 10 +++-- 9 files changed, 56 insertions(+), 59 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 5dce61c4f6..ddd671c415 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -332,7 +332,7 @@ function od_add_data_to_post_update_redirect_url_for_classic_editor( string $loc * @param array $cursor Cursor to resume from. * @return array Batch of URLs to prime metrics for and the updated cursor. */ -function od_get_batch_for_url_metrics_priming_mode( array $cursor ): array { +function od_get_priming_mode_batch( array $cursor ): array { // Get the server & its registry of sitemap providers. $server = wp_sitemaps_get_server(); $registry = $server->registry; @@ -578,7 +578,7 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { * @param array $urls Array of URLs to filter. * @return array}> Filtered batch of URL groups. */ -function od_filter_batch_urls_for_url_metrics_priming_mode( array $urls ): array { +function od_filter_priming_mode_batch_urls( array $urls ): array { $filtered_url_groups = array(); $standard_breakpoints = od_get_standard_breakpoints(); $group_collections = od_get_metrics_by_post_title( $urls ); @@ -625,36 +625,33 @@ function od_filter_batch_urls_for_url_metrics_priming_mode( array $urls ): array } /** - * Determines whether the admin-based URL priming feature should be displayed. - * - * Developers can force-enable the feature by filtering 'od_show_admin_url_priming_feature', or modify the - * threshold via 'od_admin_url_priming_threshold' filter. + * Determine whether to show the priming mode settings page. * * @since n.e.x.t * - * @return bool True if the admin URL priming feature should be displayed, false otherwise. + * @return bool True to display the settings page; false to hide it. */ -function od_show_admin_url_priming_feature(): bool { +function od_show_priming_mode_settings(): bool { /** - * Filters whether the admin URL priming feature should be shown in the admin dashboard. + * Filters whether the priming mode settings page should be shown in the admin dashboard. * * @since n.e.x.t * * @param bool $show_feature True if the feature should be shown, false otherwise. */ - $force_show = apply_filters( 'od_show_admin_url_priming_feature', false ); + $force_show = apply_filters( 'od_show_priming_mode_settings', false ); if ( $force_show ) { return true; } /** - * Filters the threshold for enabling the admin URL priming feature. + * Filters maximum number of URLs allowed before hiding the settings page. * * @since n.e.x.t * * @param int $threshold The threshold count of frontend-visible URLs. */ - $threshold = apply_filters( 'od_admin_url_priming_threshold', 1000 ); + $threshold = apply_filters( 'od_show_priming_mode_settings_max_urls', 1000 ); $count = 0; // Get the sitemap server and its registry of providers. @@ -697,7 +694,7 @@ function od_show_admin_url_priming_feature(): bool { * @param array|null $cursor Cursor to resume from. * @return array Final batch of URLs to prime metrics for and the updated cursor. */ -function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array { +function od_generate_priming_mode_batch( ?array $cursor ): array { $default_cursor = array( 'provider_index' => 0, 'subtype_index' => 0, @@ -710,12 +707,12 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( (array) $cursor, $default_cursor ), $default_cursor ) ); if ( $default_cursor === $cursor ) { - $last_cursor = get_option( 'od_prime_url_metrics_batch_cursor' ); + $last_cursor = get_option( 'od_priming_mode_batch_cursor' ); if ( false !== $last_cursor ) { $cursor = array_map( 'intval', array_intersect_key( wp_parse_args( $cursor, $last_cursor ), $last_cursor ) ); } } else { - update_option( 'od_prime_url_metrics_batch_cursor', $cursor ); + update_option( 'od_priming_mode_batch_cursor', $cursor ); } $batch = array(); @@ -726,11 +723,11 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array break; } - $batch = od_get_batch_for_url_metrics_priming_mode( $cursor ); - $filtered_url_groups = od_filter_batch_urls_for_url_metrics_priming_mode( $batch['urls'] ); + $batch = od_get_priming_mode_batch( $cursor ); + $filtered_url_groups = od_filter_priming_mode_batch_urls( $batch['urls'] ); if ( $cursor === $batch['cursor'] ) { - delete_option( 'od_prime_url_metrics_batch_cursor' ); + delete_option( 'od_priming_mode_batch_cursor' ); break; } $cursor = $batch['cursor']; @@ -741,7 +738,7 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array return array( 'urlGroups' => $filtered_url_groups, 'cursor' => $batch['cursor'], - 'verificationToken' => od_get_verification_token_for_priming_mode(), + 'verificationToken' => od_get_priming_mode_verification_token(), 'isDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ); } @@ -754,11 +751,11 @@ function od_generate_batch_for_url_metrics_priming_mode( ?array $cursor ): array * * @return string Verification token. */ -function od_get_verification_token_for_priming_mode(): string { - $verification_token = get_transient( 'od_prime_url_metrics_verification_token' ); +function od_get_priming_mode_verification_token(): string { + $verification_token = get_transient( 'od_priming_mode_verification_token' ); if ( false === $verification_token ) { $verification_token = wp_generate_uuid4(); - set_transient( 'od_prime_url_metrics_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + set_transient( 'od_priming_mode_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); } return $verification_token; } diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index b58d1cff09..7cf9cb53c9 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -76,7 +76,7 @@ * @type {HTMLIFrameElement} */ const iframe = document.createElement( 'iframe' ); - iframe.id = 'od-prime-url-metrics-iframe'; + iframe.id = 'od-priming-mode-iframe'; iframe.style.position = 'fixed'; iframe.style.top = '0'; iframe.style.left = '0'; @@ -107,14 +107,14 @@ isProcessing = true; if ( 0 === breakpoints.length ) { breakpoints = await apiFetch( { - path: '/optimization-detective/v1/prime-urls-breakpoints', + path: '/optimization-detective/v1/priming-mode-breakpoints', method: 'GET', } ); } const permalink = select( 'core/editor' ).getPermalink(); verificationToken = await apiFetch( { - path: '/optimization-detective/v1/prime-urls-verification-token', + path: '/optimization-detective/v1/priming-mode-verification-token', method: 'GET', } ); diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 9008acff89..185473b6d5 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -95,7 +95,7 @@ * @type {HTMLIFrameElement} */ const iframe = document.createElement( 'iframe' ); - iframe.id = 'od-prime-url-metrics-iframe'; + iframe.id = 'od-priming-mode-iframe'; iframe.style.position = 'fixed'; iframe.style.top = '0'; iframe.style.left = '0'; @@ -126,13 +126,13 @@ isProcessing = true; if ( 0 === breakpoints.length ) { breakpoints = await apiFetch( { - path: '/optimization-detective/v1/prime-urls-breakpoints', + path: '/optimization-detective/v1/priming-mode-breakpoints', method: 'GET', } ); } verificationToken = await apiFetch( { - path: '/optimization-detective/v1/prime-urls-verification-token', + path: '/optimization-detective/v1/priming-mode-verification-token', method: 'GET', } ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index 84555fd8a5..d09993bd5b 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -12,7 +12,7 @@ * @type {HTMLButtonElement} */ const controlButton = document.querySelector( - 'button#od-prime-url-metrics-control-button' + 'button#od-priming-mode-control-button' ); /** @@ -21,7 +21,7 @@ * @type {HTMLProgressElement} */ const progressBar = document.querySelector( - 'progress#od-prime-url-metrics-progress' + 'progress#od-priming-mode-progress' ); /** @@ -30,7 +30,7 @@ * @type {HTMLDivElement} */ const statusContainer = document.querySelector( - 'div#od-prime-url-metrics-status-container' + 'div#od-priming-mode-status-container' ); /** @@ -38,9 +38,7 @@ * * @type {HTMLIFrameElement} */ - const iframe = document.querySelector( - 'iframe#od-prime-url-metrics-iframe' - ); + const iframe = document.querySelector( 'iframe#od-priming-mode-iframe' ); /** * Container that holds the iframe. @@ -48,7 +46,7 @@ * @type {HTMLDivElement} */ const iframeContainer = document.querySelector( - 'div#od-prime-url-metrics-iframe-container' + 'div#od-priming-mode-iframe-container' ); /** @@ -57,7 +55,7 @@ * @type {HTMLSpanElement} */ const currentBatchElement = document.querySelector( - 'span#od-prime-url-metrics-current-batch' + 'span#od-priming-mode-current-batch' ); /** @@ -66,7 +64,7 @@ * @type {HTMLSpanElement} */ const currentTaskElement = document.querySelector( - 'span#od-prime-url-metrics-current-task' + 'span#od-priming-mode-current-task' ); /** @@ -75,7 +73,7 @@ * @type {HTMLSpanElement} */ const totalTasksInBatchElement = document.querySelector( - 'span#od-prime-url-metrics-total-tasks-in-batch' + 'span#od-priming-mode-total-tasks-in-batch' ); // Ensure all required elements are present. diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index ab40d38b42..e416e2a173 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -64,7 +64,7 @@ function checkEnvironment() { }, { name: 'Optimization Detective WP_CLI command', - command: 'wp help od get_url_batch', + command: 'wp help od get_priming_mode_url_batch', errorMessage: 'Optimization Detective plugin is not installed or activated. Please install and activate the plugin.', }, @@ -93,7 +93,7 @@ function checkEnvironment() { */ function getBatch( lastCursor ) { try { - let command = 'wp od get_url_batch --format=json'; + let command = 'wp od get_priming_mode_url_batch --format=json'; if ( lastCursor ) { command += ` --provider-index=${ lastCursor.provider_index || 0 }`; diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php index ffc28061d0..357b24c42c 100644 --- a/plugins/optimization-detective/settings.php +++ b/plugins/optimization-detective/settings.php @@ -19,7 +19,7 @@ * @since n.e.x.t */ function od_add_optimization_detective_menu(): void { - if ( ! od_show_admin_url_priming_feature() ) { + if ( ! od_show_priming_mode_settings() ) { return; } @@ -60,17 +60,17 @@ function od_render_optimization_detective_page(): void {

- +
- @@ -88,7 +88,7 @@ function od_render_optimization_detective_page(): void { * @return string[]|mixed The modified list of actions. */ function od_add_settings_action_link( $links ) { - if ( ! is_array( $links ) || ! od_show_admin_url_priming_feature() ) { + if ( ! is_array( $links ) || ! od_show_priming_mode_settings() ) { return $links; } diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php index 722256e74c..1a4192544c 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php @@ -31,14 +31,14 @@ final class OD_REST_URL_Metrics_Priming_Mode_Endpoint { * * @var string */ - const PRIME_URLS_BREAKPOINTS_ROUTE = '/prime-urls-breakpoints'; + const PRIME_URLS_BREAKPOINTS_ROUTE = '/priming-mode-breakpoints'; /** - * Route for verifying the token for auto priming URLs. + * Route for verifying the token for priming mode. * * @var string */ - const PRIME_URLS_VERIFICATION_TOKEN_ROUTE = '/prime-urls-verification-token'; + const PRIME_URLS_VERIFICATION_TOKEN_ROUTE = '/priming-mode-verification-token'; /** * Gets the arguments for registering the endpoint responsible for getting URLs that needs to be primed. @@ -133,7 +133,7 @@ public function priming_permissions_check() { */ public function handle_generate_batch_urls_request( WP_REST_Request $request ): WP_REST_Response { $cursor = $request->get_param( 'cursor' ); - return new WP_REST_Response( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ); + return new WP_REST_Response( od_generate_priming_mode_batch( $cursor ) ); } /** @@ -157,6 +157,6 @@ public function handle_generate_breakpoints_request(): WP_REST_Response { * @return WP_REST_Response Response. */ public function handle_get_verification_token_request(): WP_REST_Response { - return new WP_REST_Response( od_get_verification_token_for_priming_mode() ); + return new WP_REST_Response( od_get_priming_mode_verification_token() ); } } diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php index aee1b92dd8..0e7eb6888a 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php @@ -123,7 +123,7 @@ public function get_registration_args(): array { public function store_permissions_check( WP_REST_Request $request ) { // Authenticated requests when priming URL metrics through IFRAME. $verification_token = $request->get_param( 'prime_url_metrics_verification_token' ); - if ( '' !== $verification_token && get_transient( 'od_prime_url_metrics_verification_token' ) === $verification_token ) { + if ( '' !== $verification_token && get_transient( 'od_priming_mode_verification_token' ) === $verification_token ) { return true; } diff --git a/plugins/optimization-detective/storage/class-od-wp-cli.php b/plugins/optimization-detective/storage/class-od-wp-cli.php index 79ea6e3a2e..03579f33a0 100644 --- a/plugins/optimization-detective/storage/class-od-wp-cli.php +++ b/plugins/optimization-detective/storage/class-od-wp-cli.php @@ -65,15 +65,17 @@ class OD_WP_CLI { * ## EXAMPLES * * # Get a batch of URLs that need to be primed - * $ wp od get_url_batch --format=json + * $ wp od get_priming_mode_url_batch --format=json * * # List 20 URL metrics with specific pagination parameters - * $ wp od get_url_batch --provider-index=0 --subtype-index=0 --page-number=1 --offset-within-page=0 --batch-size=20 --format=json + * $ wp od get_priming_mode_url_batch --provider-index=0 --subtype-index=0 --page-number=1 --offset-within-page=0 --batch-size=20 --format=json + * + * @since n.e.x.t * * @param array $args Command arguments. * @param array $assoc_args Command associated arguments. */ - public function get_url_batch( array $args, array $assoc_args ): void { + public function get_priming_mode_url_batch( array $args, array $assoc_args ): void { $cursor = array( 'provider_index' => isset( $assoc_args['provider-index'] ) ? (int) $assoc_args['provider-index'] : 0, 'subtype_index' => isset( $assoc_args['subtype-index'] ) ? (int) $assoc_args['subtype-index'] : 0, @@ -85,7 +87,7 @@ public function get_url_batch( array $args, array $assoc_args ): void { $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; if ( function_exists( '\\WP_CLI\\Utils\\format_items' ) ) { - WP_CLI\Utils\format_items( $format, array( od_generate_batch_for_url_metrics_priming_mode( $cursor ) ), array( 'urlGroups', 'cursor', 'verificationToken', 'isDebug' ) ); + WP_CLI\Utils\format_items( $format, array( od_generate_priming_mode_batch( $cursor ) ), array( 'urlGroups', 'cursor', 'verificationToken', 'isDebug' ) ); } } } From 78c838d118a7bf6762350647a810ef0e15bf6bfa Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 29 Aug 2025 23:25:16 +0530 Subject: [PATCH 58/62] Improve priming mode verification token handling with auto-refresh --- plugins/optimization-detective/detect.js | 5 +- plugins/optimization-detective/load.php | 2 +- .../prime-url-metrics.js | 20 ++++++- .../priming-cli/index.js | 53 +++++++++++++++---- ...i.php => class-od-priming-mode-wp-cli.php} | 33 +++++++++--- ...ass-od-rest-url-metrics-store-endpoint.php | 11 +++- 6 files changed, 101 insertions(+), 23 deletions(-) rename plugins/optimization-detective/storage/{class-od-wp-cli.php => class-od-priming-mode-wp-cli.php} (71%) diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index abaed74faf..38300a01b8 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1267,9 +1267,8 @@ export default async function detect( { try { const response = await fetch( request ); if ( ! response.ok ) { - throw new Error( - `Failed to send URL Metric. Status: ${ response.status }` - ); + const errorData = await response.json(); + throw new Error( errorData.code ); } notifyStatus( { success: true }, primeModeSource ); } catch ( err ) { diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 42e8b92163..952d663768 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -141,6 +141,6 @@ static function ( string $version ): void { require_once __DIR__ . '/settings.php'; // Load WP-CLI commands. - require_once __DIR__ . '/storage/class-od-wp-cli.php'; + require_once __DIR__ . '/storage/class-od-priming-mode-wp-cli.php'; } ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index d09993bd5b..ced74803fc 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -318,7 +318,13 @@ ); } catch ( error ) { log( error.message ); - if ( abortController.signal.aborted ) { + if ( + error.message.includes( + 'priming_mode_verification_token_expired' + ) + ) { + verificationToken = await getVerificationToken(); + } else if ( abortController.signal.aborted ) { throw error; } } @@ -361,6 +367,18 @@ } ); } + /** + * Fetches the verification token for priming mode. + * + * @return {Promise} - Resolves with the verification token. + */ + async function getVerificationToken() { + return await apiFetch( { + path: '/optimization-detective/v1/priming-mode-verification-token', + method: 'GET', + } ); + } + /** * Loads the iframe and waits for the message. * diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index e416e2a173..68eccaaa88 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -64,7 +64,7 @@ function checkEnvironment() { }, { name: 'Optimization Detective WP_CLI command', - command: 'wp help od get_priming_mode_url_batch', + command: 'wp help od priming-mode get-url-batch', errorMessage: 'Optimization Detective plugin is not installed or activated. Please install and activate the plugin.', }, @@ -93,7 +93,7 @@ function checkEnvironment() { */ function getBatch( lastCursor ) { try { - let command = 'wp od get_priming_mode_url_batch --format=json'; + let command = 'wp od priming-mode get-url-batch --format=json'; if ( lastCursor ) { command += ` --provider-index=${ lastCursor.provider_index || 0 }`; @@ -119,6 +119,31 @@ function getBatch( lastCursor ) { } } +/** + * Fetches the verification token. + * + * @return {string|null} - The verification token or null if not available. + */ +function getVerificationToken() { + try { + const verificationToken = execSync( + 'wp od priming-mode get-verification-token' + ) + .toString() + .trim(); + if ( '' === verificationToken ) { + throw new Error( 'Invalid verification token received.' ); + } + return verificationToken; + } catch ( error ) { + spinner.fail( + 'Error occurred while fetching verification token: ' + error.message + ); + abortController.abort(); + return null; + } +} + /** * Flattens the url groups to tasks. * @@ -289,7 +314,7 @@ async function init() { * * @type {string} */ - let verificationToken; + let verificationToken = null; // Process batches until no more are available. while ( isNextBatchAvailable ) { @@ -339,12 +364,22 @@ async function init() { signal ); } catch ( error ) { - // Log the error and continue processing the next task. - spinner.fail( - `Error processing task ${ i + 1 }. Error: ${ - error.message - }` - ); + // Refresh verification token if expired. + if ( + error.message.includes( + 'priming_mode_verification_token_expired' + ) + ) { + verificationToken = getVerificationToken(); + i--; + } else { + // Log the error and continue processing the next task. + spinner.fail( + `Error processing task ${ i + 1 }. Error: ${ + error.message + }` + ); + } } } cursor = currentBatch.cursor; diff --git a/plugins/optimization-detective/storage/class-od-wp-cli.php b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php similarity index 71% rename from plugins/optimization-detective/storage/class-od-wp-cli.php rename to plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php index 03579f33a0..506ca7da42 100644 --- a/plugins/optimization-detective/storage/class-od-wp-cli.php +++ b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php @@ -1,6 +1,6 @@ $args Command arguments. * @param array $assoc_args Command associated arguments. */ - public function get_priming_mode_url_batch( array $args, array $assoc_args ): void { + public function get_url_batch( array $args, array $assoc_args ): void { $cursor = array( 'provider_index' => isset( $assoc_args['provider-index'] ) ? (int) $assoc_args['provider-index'] : 0, 'subtype_index' => isset( $assoc_args['subtype-index'] ) ? (int) $assoc_args['subtype-index'] : 0, @@ -90,7 +92,24 @@ public function get_priming_mode_url_batch( array $args, array $assoc_args ): vo WP_CLI\Utils\format_items( $format, array( od_generate_priming_mode_batch( $cursor ) ), array( 'urlGroups', 'cursor', 'verificationToken', 'isDebug' ) ); } } + + /** + * Gets the priming mode verification token. + * + * ## EXAMPLES + * + * # Get the priming mode verification token + * $ wp od get-verification-token + * + * @subcommand get-verification-token + * + * @since n.e.x.t + */ + public function get_verification_token(): void { + // @phpstan-ignore-next-line + WP_CLI::line( od_get_priming_mode_verification_token() ); + } } // Register the WP-CLI command. -WP_CLI::add_command( 'od', new OD_WP_CLI() ); +WP_CLI::add_command( 'od priming-mode', OD_Priming_Mode_WP_CLI::class ); diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php index 0e7eb6888a..d5708fd50c 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-store-endpoint.php @@ -123,8 +123,15 @@ public function get_registration_args(): array { public function store_permissions_check( WP_REST_Request $request ) { // Authenticated requests when priming URL metrics through IFRAME. $verification_token = $request->get_param( 'prime_url_metrics_verification_token' ); - if ( '' !== $verification_token && get_transient( 'od_priming_mode_verification_token' ) === $verification_token ) { - return true; + if ( null !== $verification_token && '' !== $verification_token ) { + if ( get_transient( 'od_priming_mode_verification_token' ) === $verification_token ) { + return true; + } + return new WP_Error( + 'priming_mode_verification_token_expired', + __( 'The priming mode verification token has expired.', 'optimization-detective' ), + array( 'status' => 401 ) + ); } // Needs to be available to unauthenticated visitors. From d73842a4e8fe537eabfd1381bec1185700700587 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Mon, 1 Sep 2025 16:17:17 +0530 Subject: [PATCH 59/62] Cache sitemap URL count for priming mode --- plugins/optimization-detective/helper.php | 20 +++++++++++++------- plugins/optimization-detective/uninstall.php | 3 +++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index ddd671c415..a7b6db1016 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -633,14 +633,14 @@ function od_filter_priming_mode_batch_urls( array $urls ): array { */ function od_show_priming_mode_settings(): bool { /** - * Filters whether the priming mode settings page should be shown in the admin dashboard. + * Filters whether the priming mode settings page should be shown in the admin dashboard, regardless of the number of URLs. * * @since n.e.x.t * * @param bool $show_feature True if the feature should be shown, false otherwise. */ $force_show = apply_filters( 'od_show_priming_mode_settings', false ); - if ( $force_show ) { + if ( true === $force_show ) { return true; } @@ -652,12 +652,17 @@ function od_show_priming_mode_settings(): bool { * @param int $threshold The threshold count of frontend-visible URLs. */ $threshold = apply_filters( 'od_show_priming_mode_settings_max_urls', 1000 ); - $count = 0; + + $count = (int) get_transient( 'od_priming_mode_frontend_visible_url_count' ); + if ( 0 !== $count ) { + return $count <= $threshold; + } // Get the sitemap server and its registry of providers. $server = wp_sitemaps_get_server(); $registry = $server->registry; $providers = array_values( $registry->get_providers() ); + $show = true; foreach ( $providers as $provider ) { // Each provider returns its object subtypes (e.g. 'post', 'page', etc.). @@ -673,16 +678,17 @@ function od_show_priming_mode_settings(): bool { $url_chunk = array_filter( array_column( $url_list, 'loc' ) ); $count += count( $url_chunk ); - // If we reach the threshold, disable the admin priming feature. if ( $count >= $threshold ) { - return false; + $show = false; + break 3; } } } } - // If we reach here, the count is less than the threshold. Enable the admin priming feature. - return true; + set_transient( 'od_priming_mode_frontend_visible_url_count', $count, DAY_IN_SECONDS ); + + return $show; } /** diff --git a/plugins/optimization-detective/uninstall.php b/plugins/optimization-detective/uninstall.php index 3400386724..6afdbf0020 100644 --- a/plugins/optimization-detective/uninstall.php +++ b/plugins/optimization-detective/uninstall.php @@ -21,6 +21,9 @@ // Clear out site health check data. delete_option( 'od_rest_api_unavailable' ); delete_transient( 'od_rest_api_health_check_response' ); + + // Clear out priming mode data. + delete_transient( 'od_priming_mode_frontend_visible_url_count' ); }; $od_delete_site_data(); From 5b7a26c545046a58ad27be4f7e84e6b59b1b0f7d Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Tue, 9 Sep 2025 19:34:38 +0530 Subject: [PATCH 60/62] Replace computed breakpoints with real device viewports Co-authored-by: Weston Ruter --- plugins/optimization-detective/detection.php | 4 +- plugins/optimization-detective/helper.php | 83 ++++++++++--------- .../prime-url-metrics-block-editor.js | 24 +++--- .../prime-url-metrics-classic-editor.js | 26 +++--- .../prime-url-metrics.js | 8 +- .../priming-cli/index.js | 6 +- .../priming-cli/types.ts | 4 +- ...rest-url-metrics-priming-mode-endpoint.php | 16 ++-- .../optimization-detective/storage/data.php | 4 +- plugins/optimization-detective/types.ts | 4 +- 10 files changed, 90 insertions(+), 89 deletions(-) diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 740db1f59e..b6af99403c 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -252,8 +252,8 @@ function od_register_rest_url_metric_priming_endpoint(): void { ); register_rest_route( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, - $endpoint_controller::PRIME_URLS_BREAKPOINTS_ROUTE, - $endpoint_controller->get_registration_args_prime_urls_breakpoints() + $endpoint_controller::PRIME_URLS_VIEWPORTS_ROUTE, + $endpoint_controller->get_registration_args_prime_urls_viewports() ); register_rest_route( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE, diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index a7b6db1016..406d89943d 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -533,40 +533,41 @@ function od_get_metrics_by_post_title( array $urls ): array { } /** - * Computes the standard array of breakpoints. + * Gets the standard array of viewport based on real world device. * * @since n.e.x.t * @access private * - * @return array Array of breakpoints. + * @return array Array of viewports. */ -function od_get_standard_breakpoints(): array { - $widths = od_get_breakpoint_max_widths(); - sort( $widths ); - - $min_width = $widths[0]; - $max_width = (int) end( $widths ) + 300; // For large screens. - $widths[] = $max_width; - - // We need to ensure min is 0.56 (1080/1920) else the height becomes too small. - $min_ar = max( 0.56, od_get_minimum_viewport_aspect_ratio() ); - // Ensure max is 1.78 (1920/1080) else the height becomes too large. - $max_ar = min( 1.78, od_get_maximum_viewport_aspect_ratio() ); - - // Compute [width => height] for each breakpoint. - return array_map( - static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { - // Linear interpolation between max_ar and min_ar based on width. - $ar = $max_ar - ( ( $max_ar - $min_ar ) * ( ( $width - $min_width ) / ( $max_width - $min_width ) ) ); - $ar = max( $min_ar, min( $max_ar, $ar ) ); - - return array( - 'width' => $width, - 'height' => (int) round( $ar * $width ), - ); - }, - $widths +function od_get_standard_viewports(): array { + $device_viewports = array( + array( // Small smartphones. + 'width' => 360, + 'height' => 780, + ), + array( // Large smartphones. + 'width' => 414, + 'height' => 896, + ), + array( // Tablets. + 'width' => 768, + 'height' => 1024, + ), + array( // Desktop/laptop screens. + 'width' => 1920, + 'height' => 1080, + ), ); + + /** + * Filters the standard device viewports used for priming mode. + * + * @since n.e.x.t + * + * @param array $device_viewports Array of viewport dimensions. + */ + return apply_filters( 'od_standard_viewports', $device_viewports ); } /** @@ -576,19 +577,19 @@ static function ( $width ) use ( $min_width, $max_width, $min_ar, $max_ar ) { * @access private * * @param array $urls Array of URLs to filter. - * @return array}> Filtered batch of URL groups. + * @return array}> Filtered batch of URL groups. */ function od_filter_priming_mode_batch_urls( array $urls ): array { - $filtered_url_groups = array(); - $standard_breakpoints = od_get_standard_breakpoints(); - $group_collections = od_get_metrics_by_post_title( $urls ); + $filtered_url_groups = array(); + $standard_viewports = od_get_standard_viewports(); + $group_collections = od_get_metrics_by_post_title( $urls ); foreach ( $urls as $url ) { $group_collection = $group_collections[ $url ] ?? null; if ( ! $group_collection instanceof OD_URL_Metric_Group_Collection ) { $filtered_url_groups[] = array( - 'url' => $url, - 'breakpoints' => $standard_breakpoints, + 'url' => $url, + 'viewports' => $standard_viewports, ); continue; } @@ -606,17 +607,17 @@ function od_filter_priming_mode_batch_urls( array $urls ): array { } } - $missing_breakpoints = array(); - foreach ( $standard_breakpoints as $breakpoint ) { - if ( ! in_array( $breakpoint['width'], $existing_widths, true ) ) { - $missing_breakpoints[] = $breakpoint; + $missing_viewports = array(); + foreach ( $standard_viewports as $viewport ) { + if ( ! in_array( $viewport['width'], $existing_widths, true ) ) { + $missing_viewports[] = $viewport; } } - if ( count( $missing_breakpoints ) > 0 ) { + if ( count( $missing_viewports ) > 0 ) { $filtered_url_groups[] = array( - 'url' => $url, - 'breakpoints' => $missing_breakpoints, + 'url' => $url, + 'viewports' => $missing_viewports, ); } } diff --git a/plugins/optimization-detective/prime-url-metrics-block-editor.js b/plugins/optimization-detective/prime-url-metrics-block-editor.js index 7cf9cb53c9..dfd0bfefb8 100644 --- a/plugins/optimization-detective/prime-url-metrics-block-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -22,14 +22,14 @@ let verificationToken = ''; /** - * Array of breakpoint objects defining viewport dimensions. + * Array of viewport objects defining dimensions. * - * @type {import("./types.ts").ViewportBreakpoint[]} + * @type {import("./types.ts").Viewport[]} */ - let breakpoints = []; + let viewports = []; /** - * Queue of URL priming tasks generated from breakpoints. + * Queue of URL priming tasks generated from viewports. * * @type {import("./types.ts").URLPrimingTask[]} */ @@ -98,16 +98,16 @@ } /** - * Primes the URL metrics for all breakpoints. + * Primes the URL metrics for all viewports. * * @return {Promise} The promise that resolves to void. */ async function processTasks() { try { isProcessing = true; - if ( 0 === breakpoints.length ) { - breakpoints = await apiFetch( { - path: '/optimization-detective/v1/priming-mode-breakpoints', + if ( 0 === viewports.length ) { + viewports = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-viewports', method: 'GET', } ); } @@ -118,10 +118,10 @@ method: 'GET', } ); - currentTasks = breakpoints.map( ( breakpoint ) => ( { + currentTasks = viewports.map( ( viewport ) => ( { url: permalink, - width: breakpoint.width, - height: breakpoint.height, + width: viewport.width, + height: viewport.height, } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { @@ -147,7 +147,7 @@ /** * Loads the iframe and waits for the message. * - * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ diff --git a/plugins/optimization-detective/prime-url-metrics-classic-editor.js b/plugins/optimization-detective/prime-url-metrics-classic-editor.js index 185473b6d5..d40a0f1be3 100644 --- a/plugins/optimization-detective/prime-url-metrics-classic-editor.js +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -34,14 +34,14 @@ let verificationToken = ''; /** - * Array of viewport breakpoint objects defining dimensions. + * Array of viewport objects defining dimensions. * - * @type {import("./types.ts").ViewportBreakpoint[]} + * @type {import("./types.ts").Viewport[]} */ - let breakpoints = []; + let viewports = []; /** - * Queue of URL priming tasks generated from breakpoints. + * Queue of URL priming tasks generated from viewports. * * @type {import("./types.ts").URLPrimingTask[]} */ @@ -117,16 +117,16 @@ } /** - * Primes the URL metrics for all breakpoints. + * Primes the URL metrics for all viewports. * * @return {Promise} The promise that resolves to void. */ async function processTasks() { try { isProcessing = true; - if ( 0 === breakpoints.length ) { - breakpoints = await apiFetch( { - path: '/optimization-detective/v1/priming-mode-breakpoints', + if ( 0 === viewports.length ) { + viewports = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-viewports', method: 'GET', } ); } @@ -136,10 +136,10 @@ method: 'GET', } ); - currentTasks = breakpoints.map( ( breakpoint ) => ( { + currentTasks = viewports.map( ( viewport ) => ( { url: permalink, - width: breakpoint.width, - height: breakpoint.height, + width: viewport.width, + height: viewport.height, } ) ); while ( isProcessing && currentTaskIndex < currentTasks.length ) { @@ -165,7 +165,7 @@ /** * Loads the iframe and waits for the message. * - * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ @@ -291,7 +291,7 @@ // Attach event listeners. /** - * Primes the URL metrics for all breakpoints + * Primes the URL metrics for all viewports * when the document is ready. */ document.addEventListener( 'DOMContentLoaded', processTasks ); diff --git a/plugins/optimization-detective/prime-url-metrics.js b/plugins/optimization-detective/prime-url-metrics.js index ced74803fc..1680d38e58 100644 --- a/plugins/optimization-detective/prime-url-metrics.js +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -344,10 +344,10 @@ */ function flattenBatchToTasks( urlGroups ) { return urlGroups.flatMap( ( urlGroup ) => - urlGroup.breakpoints.map( ( breakpoint ) => ( { + urlGroup.viewports.map( ( viewport ) => ( { url: urlGroup.url, - width: breakpoint.width, - height: breakpoint.height, + width: viewport.width, + height: viewport.height, } ) ) ); } @@ -382,7 +382,7 @@ /** * Loads the iframe and waits for the message. * - * @param {import("./types.ts").URLPrimingTask} task - The breakpoint to set for the iframe. + * @param {import("./types.ts").URLPrimingTask} task - The viewport to set for the iframe. * @param {AbortSignal} signal - The signal to abort the task. * @return {Promise} The promise that resolves to void. */ diff --git a/plugins/optimization-detective/priming-cli/index.js b/plugins/optimization-detective/priming-cli/index.js index 68eccaaa88..7b2ca44440 100644 --- a/plugins/optimization-detective/priming-cli/index.js +++ b/plugins/optimization-detective/priming-cli/index.js @@ -152,10 +152,10 @@ function getVerificationToken() { */ function flattenBatchToTasks( urlGroups ) { return urlGroups.flatMap( ( urlGroup ) => - urlGroup.breakpoints.map( ( breakpoint ) => ( { + urlGroup.viewports.map( ( viewport ) => ( { url: urlGroup.url, - width: breakpoint.width, - height: breakpoint.height, + width: viewport.width, + height: viewport.height, } ) ) ); } diff --git a/plugins/optimization-detective/priming-cli/types.ts b/plugins/optimization-detective/priming-cli/types.ts index f57e91dba6..d77db87051 100644 --- a/plugins/optimization-detective/priming-cli/types.ts +++ b/plugins/optimization-detective/priming-cli/types.ts @@ -6,14 +6,14 @@ export interface URLBatchCursor { batch_size: number; } -export interface ViewportBreakpoint { +export interface Viewport { width: number; height: number; } export interface URLGroup { url: string; - breakpoints: ViewportBreakpoint[]; + viewports: Viewport[]; } export interface URLBatchResponse { diff --git a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php index 1a4192544c..075195727c 100644 --- a/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php @@ -27,11 +27,11 @@ final class OD_REST_URL_Metrics_Priming_Mode_Endpoint { const PRIME_URLS_ROUTE = '/prime-urls'; /** - * Route for getting breakpoints for URL Metrics. + * Route for getting viewports for URL Metrics. * * @var string */ - const PRIME_URLS_BREAKPOINTS_ROUTE = '/priming-mode-breakpoints'; + const PRIME_URLS_VIEWPORTS_ROUTE = '/priming-mode-viewports'; /** * Route for verifying the token for priming mode. @@ -61,7 +61,7 @@ public function get_registration_args_prime_urls(): array { } /** - * Gets the arguments for registering the endpoint responsible for getting breakpoints for priming URL Metrics. + * Gets the arguments for registering the endpoint responsible for getting viewports for priming URL Metrics. * * @since n.e.x.t * @access private @@ -72,10 +72,10 @@ public function get_registration_args_prime_urls(): array { * permission_callback: callable * } */ - public function get_registration_args_prime_urls_breakpoints(): array { + public function get_registration_args_prime_urls_viewports(): array { return array( 'methods' => 'GET', - 'callback' => array( $this, 'handle_generate_breakpoints_request' ), + 'callback' => array( $this, 'handle_generate_viewports_request' ), 'permission_callback' => array( $this, 'priming_permissions_check' ), ); } @@ -137,15 +137,15 @@ public function handle_generate_batch_urls_request( WP_REST_Request $request ): } /** - * Handles REST API request to generate breakpoints for URL Metrics. + * Handles REST API request to generate viewports for URL Metrics. * * @since n.e.x.t * @access private * * @return WP_REST_Response Response. */ - public function handle_generate_breakpoints_request(): WP_REST_Response { - return new WP_REST_Response( od_get_standard_breakpoints() ); + public function handle_generate_viewports_request(): WP_REST_Response { + return new WP_REST_Response( od_get_standard_viewports() ); } /** diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 36d2f4c31a..c40730f0d1 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -387,9 +387,9 @@ static function ( $original_breakpoint ): int { * @since 0.1.0 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_breakpoint_max_widths * - * @param positive-int[] $breakpoint_max_widths Max widths for viewport breakpoints. Defaults to [480, 600, 782]. + * @param positive-int[] $breakpoint_max_widths Max widths for viewport breakpoints. Defaults to [380, 480, 600, 782]. */ - array_map( 'intval', (array) apply_filters( 'od_breakpoint_max_widths', array( 480, 600, 782 ) ) ) + array_map( 'intval', (array) apply_filters( 'od_breakpoint_max_widths', array( 380, 480, 600, 782 ) ) ) ); $breakpoint_max_widths = array_unique( $breakpoint_max_widths, SORT_NUMERIC ); diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index 555a6f1254..4c303135ae 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -114,14 +114,14 @@ export interface URLBatchCursor { batch_size: number; } -export interface ViewportBreakpoint { +export interface Viewport { width: number; height: number; } export interface URLGroup { url: string; - breakpoints: ViewportBreakpoint[]; + viewports: Viewport[]; } export interface URLBatchResponse { From fd94d9de87092984f4ab97664df1dfe5b802a891 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Wed, 10 Sep 2025 15:54:02 +0530 Subject: [PATCH 61/62] Fix failing tests caused by the addition of a new breakpoint max width --- plugins/optimization-detective/tests/storage/test-data.php | 2 +- .../tests/test-cases/admin-bar/expected.html | 2 +- .../tests/test-cases/complete-url-metrics/expected.html | 2 +- .../tests/test-cases/many-images/expected.html | 2 +- .../tests/test-cases/no-url-metrics/expected.html | 2 +- .../tests/test-cases/noscript/expected.html | 2 +- .../tests/test-cases/preload-link/expected.html | 2 +- .../tests/test-cases/tag-track-opt-in/expected.html | 2 +- .../optimization-detective/tests/test-cases/video/expected.html | 2 +- .../tests/test-cases/xhtml-response/expected.html | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 180ae7ea97..c9b1959f26 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -682,7 +682,7 @@ static function () { */ public function test_od_get_breakpoint_max_widths(): void { $this->assertSame( - array( 480, 600, 782 ), + array( 380, 480, 600, 782 ), od_get_breakpoint_max_widths() ); diff --git a/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html b/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html index d84db44738..7eb6a14ff8 100644 --- a/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html +++ b/plugins/optimization-detective/tests/test-cases/admin-bar/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html b/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html index a75db4ffa3..f3953cd2b7 100644 --- a/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html +++ b/plugins/optimization-detective/tests/test-cases/complete-url-metrics/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/many-images/expected.html b/plugins/optimization-detective/tests/test-cases/many-images/expected.html index 6336800ddd..a82d6f2a89 100644 --- a/plugins/optimization-detective/tests/test-cases/many-images/expected.html +++ b/plugins/optimization-detective/tests/test-cases/many-images/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html b/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html index fd352f52b6..df9695dad6 100644 --- a/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html +++ b/plugins/optimization-detective/tests/test-cases/no-url-metrics/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/noscript/expected.html b/plugins/optimization-detective/tests/test-cases/noscript/expected.html index 7d65ce4d7a..1edf887a54 100644 --- a/plugins/optimization-detective/tests/test-cases/noscript/expected.html +++ b/plugins/optimization-detective/tests/test-cases/noscript/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/preload-link/expected.html b/plugins/optimization-detective/tests/test-cases/preload-link/expected.html index 8208541495..afae4926d2 100644 --- a/plugins/optimization-detective/tests/test-cases/preload-link/expected.html +++ b/plugins/optimization-detective/tests/test-cases/preload-link/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html b/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html index b3d821466f..a8f2770bde 100644 --- a/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html +++ b/plugins/optimization-detective/tests/test-cases/tag-track-opt-in/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/video/expected.html b/plugins/optimization-detective/tests/test-cases/video/expected.html index 8204aa2439..40a3912921 100644 --- a/plugins/optimization-detective/tests/test-cases/video/expected.html +++ b/plugins/optimization-detective/tests/test-cases/video/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html b/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html index 6bf4e96222..cab0be33d5 100644 --- a/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html +++ b/plugins/optimization-detective/tests/test-cases/xhtml-response/expected.html @@ -4,7 +4,7 @@ XHTML 1.0 Strict Example - + From 49437682e3ab0a792095d2b3ddbde25ce6891908 Mon Sep 17 00:00:00 2001 From: Aditya Dhade Date: Fri, 12 Sep 2025 14:02:35 +0530 Subject: [PATCH 62/62] Fix missed failing tests caused by the addition of a new breakpoint max width --- .../all-embeds-inside-viewport/expected.html | 29 ++- .../nested-figure-embed/expected.html | 8 +- .../expected.html | 5 +- .../expected.html | 2 +- .../expected.html | 5 +- .../expected.html | 15 +- .../set-up.php | 2 +- .../expected.html | 5 +- .../expected.html | 5 +- .../expected.html | 19 +- .../set-up.php | 2 +- .../expected.html | 5 +- .../expected.html | 8 +- .../expected.html | 5 +- .../expected.html | 5 +- .../expected.html | 4 +- .../expected.html | 5 +- .../too-many-bookmarks/expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../set-up.php | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../expected.html | 2 +- .../test-cases/no-url-metrics/expected.html | 2 +- .../expected.html | 2 +- .../set-up.php | 2 +- .../url-metrics.json | 192 ++++++++++++++++++ .../expected.html | 2 +- .../set-up.php | 2 +- 40 files changed, 289 insertions(+), 76 deletions(-) diff --git a/plugins/embed-optimizer/tests/test-cases/all-embeds-inside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/all-embeds-inside-viewport/expected.html index 3dbb1461f7..04ff93725f 100644 --- a/plugins/embed-optimizer/tests/test-cases/all-embeds-inside-viewport/expected.html +++ b/plugins/embed-optimizer/tests/test-cases/all-embeds-inside-viewport/expected.html @@ -2,58 +2,67 @@ ... - + - - + +
diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile/set-up.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile/set-up.php index 6395b829f4..ecb9a81781 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile/set-up.php +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile/set-up.php @@ -10,7 +10,7 @@ ); // Embed not visible on mobile. - if ( 480 === $viewport_width ) { + if ( 380 === $viewport_width ) { $elements[0]['intersectionRatio'] = 0; $elements[0]['isLCP'] = false; } diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport/expected.html index 022341c00d..dd12c6038d 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport/expected.html +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport/expected.html @@ -2,10 +2,11 @@ ... - + - - - - + + + +
diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile/set-up.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile/set-up.php index 6dc33559b6..5566044308 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile/set-up.php +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile/set-up.php @@ -10,7 +10,7 @@ ); // Embed not visible on mobile. - if ( 480 === $viewport_width ) { + if ( 380 === $viewport_width ) { $elements[0]['intersectionRatio'] = 0; $elements[0]['isLCP'] = false; } diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport/expected.html index d823c0d737..74df9370ba 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport/expected.html +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport/expected.html @@ -2,10 +2,11 @@ ... - + - - + +
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html index e854330807..50eee7584b 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport/expected.html @@ -2,10 +2,11 @@ ... - + diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html index 4aa7f57249..873997c756 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport/expected.html @@ -2,10 +2,11 @@ ... - + - +
diff --git a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php index 2063a0ff4a..6a392ee518 100644 --- a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php +++ b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-non-consecutive-viewport-groups-with-missing-data-for-middle-group/set-up.php @@ -4,7 +4,7 @@ od_get_url_metrics_slug( od_get_normalized_query_vars() ), $test_case->get_sample_url_metric( array( - 'viewport_width' => 400, + 'viewport_width' => 300, 'elements' => array( array( 'isLCP' => true, diff --git a/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html index 80af67ffb5..1c071f2377 100644 --- a/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-already-on-common-lcp-image-with-fully-populated-sample-data/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html b/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html index 8b4aabd867..2f74c79c58 100644 --- a/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/images-located-above-or-along-initial-viewport/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html b/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html index ba87599e0e..da5669b295 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-lcp-image-or-background-image-outside-viewport-with-populated-url-metrics/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html index 45e8ff0bfc..8858b08b59 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-but-server-side-heuristics-added-fetchpriority-high/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html index 04ad38a08f..5a55dd2876 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-for-image-without-src/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html index 88c44d6de1..0f818b7280 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-background-image/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html index e89080aaf9..3ae34c5266 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-data-url-image/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html index b859e92914..7b69d2048d 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics-with-non-background-image-style/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html b/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html index ce5aa92785..b9cc0818b2 100644 --- a/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/no-url-metrics/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html index 8c559ddcf7..6c3a03dd67 100644 --- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/expected.html @@ -2,7 +2,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php index 0ca81bc869..92df2d1d14 100644 --- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php +++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/set-up.php @@ -16,7 +16,7 @@ static function ( array $url_metric_data ) use ( $current_etag ): OD_URL_Metric $url_metrics_data ), $current_etag, - array( 480, 600, 782 ), + array( 380, 480, 600, 782 ), 3, WEEK_IN_SECONDS ); diff --git a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json index 06a490fed2..dac2c07d0b 100644 --- a/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json +++ b/plugins/image-prioritizer/tests/test-cases/preload-links-with-one-half-stale-group/url-metrics.json @@ -191,6 +191,198 @@ "uuid": "7dfaa404-bab1-41ff-9e10-a0e8c855d586", "etag": "f8527651f96776745f88cc49df70b62d" }, + { + "url": "https://example.net/", + "viewport": { + "width": 414, + "height": 896 + }, + "elements": [ + { + "isLCP": true, + "isLCPCandidate": true, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 1, + "intersectionRect": { + "x": 30, + "y": 182.5, + "width": 300.83331298828125, + "height": 200.546875, + "top": 182.5, + "right": 330.83331298828125, + "bottom": 383.046875, + "left": 30 + }, + "boundingClientRect": { + "x": 30, + "y": 182.5, + "width": 300.83331298828125, + "height": 200.546875, + "top": 182.5, + "right": 330.83331298828125, + "bottom": 383.046875, + "left": 30 + } + }, + { + "isLCP": false, + "isLCPCandidate": false, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 0, + "intersectionRect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + }, + "boundingClientRect": { + "x": 30, + "y": 899.5572509765625, + "width": 300.83331298828125, + "height": 200.546875, + "top": 899.5572509765625, + "right": 330.83331298828125, + "bottom": 1100.1041259765625, + "left": 30 + } + } + ], + "timestamp": 1740979411.720624, + "uuid": "913270c1-7903-4fa5-8020-537a88b099ce", + "etag": "f8527651f96776745f88cc49df70b62d" + }, + { + "url": "https://example.net/", + "viewport": { + "width": 414, + "height": 896 + }, + "elements": [ + { + "isLCP": true, + "isLCPCandidate": true, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 1, + "intersectionRect": { + "x": 30, + "y": 228.4895782470703, + "width": 300.83331298828125, + "height": 200.546875, + "top": 228.4895782470703, + "right": 330.83331298828125, + "bottom": 429.0364532470703, + "left": 30 + }, + "boundingClientRect": { + "x": 30, + "y": 228.4895782470703, + "width": 300.83331298828125, + "height": 200.546875, + "top": 228.4895782470703, + "right": 330.83331298828125, + "bottom": 429.0364532470703, + "left": 30 + } + }, + { + "isLCP": false, + "isLCPCandidate": false, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 0, + "intersectionRect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + }, + "boundingClientRect": { + "x": 30, + "y": 945.546875, + "width": 300.83331298828125, + "height": 200.546875, + "top": 945.546875, + "right": 330.83331298828125, + "bottom": 1146.09375, + "left": 30 + } + } + ], + "timestamp": 1740979341.748015, + "uuid": "1ea8ffa1-0af2-4af1-8a01-0728af72a12f", + "etag": "f8527651f96776745f88cc49df70b62d" + }, + { + "url": "https://example.net/", + "viewport": { + "width": 414, + "height": 896 + }, + "elements": [ + { + "isLCP": true, + "isLCPCandidate": true, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[1][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 1, + "intersectionRect": { + "x": 30, + "y": 228.4895782470703, + "width": 300.83331298828125, + "height": 200.546875, + "top": 228.4895782470703, + "right": 330.83331298828125, + "bottom": 429.0364532470703, + "left": 30 + }, + "boundingClientRect": { + "x": 30, + "y": 228.4895782470703, + "width": 300.83331298828125, + "height": 200.546875, + "top": 228.4895782470703, + "right": 330.83331298828125, + "bottom": 429.0364532470703, + "left": 30 + } + }, + { + "isLCP": false, + "isLCPCandidate": false, + "xpath": "/HTML/BODY/DIV[@class='wp-site-blocks']/*[2][self::MAIN]/*[2][self::DIV]/*[1][self::UL]/*[2][self::LI]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::A]/*[1][self::IMG]", + "intersectionRatio": 0, + "intersectionRect": { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + }, + "boundingClientRect": { + "x": 30, + "y": 945.546875, + "width": 300.83331298828125, + "height": 200.546875, + "top": 945.546875, + "right": 330.83331298828125, + "bottom": 1146.09375, + "left": 30 + } + } + ], + "timestamp": 1740979334.440874, + "uuid": "7e13830d-48d5-4f1b-81e2-53f6c1f2f50b", + "etag": "f8527651f96776745f88cc49df70b62d" + }, { "url": "https://example.net/", "viewport": { diff --git a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html index e153caccc4..65c42fd350 100644 --- a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html +++ b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/expected.html @@ -3,7 +3,7 @@ ... - + diff --git a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php index 6bbf0e6d97..7976525fbd 100644 --- a/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php +++ b/plugins/image-prioritizer/tests/test-cases/url-metric-only-captured-for-one-breakpoint/set-up.php @@ -4,7 +4,7 @@ od_get_url_metrics_slug( od_get_normalized_query_vars() ), $test_case->get_sample_url_metric( array( - 'viewport_width' => 400, + 'viewport_width' => 300, 'element' => array( 'isLCP' => true, 'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[1][self::IMG]',