diff --git a/.github/workflows/js-lint.yml b/.github/workflows/js-lint.yml index 8dac7fa65f..efe025f8d4 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 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 548fc3b3b6..3e6170ea73 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,66 +2,75 @@ ... - + - - + +
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 5a8fc575d4..bb3acee7fe 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 d8b1e77a3e..31b26eacd6 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 963505b1b1..33c517b381 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 338c963b18..d567e4c57c 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 dcc0d86109..9622c21694 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 ee4c8f9560..207f0d5a80 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 5aabb64638..b4409cf966 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 8247a06bff..a949c34d83 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 4592fc39a5..5a7cb9a6f5 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 be90c68224..b11fbde884 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 06c23047a4..a4d9807c7c 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 cee55dff40..8221577b1a 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]', diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 6dc4e9bac6..8496e55614 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?: 'visitor'|'user'|'synthetic', * } * @phpstan-type JSONSchema array{ * type: string|string[], @@ -310,6 +311,17 @@ public static function get_json_schema(): array { 'additionalProperties' => true, ), ), + 'source' => array( + 'description' => __( 'The source of the URL Metric.', 'optimization-detective' ), + 'type' => 'string', + 'required' => false, + 'readonly' => true, // Omit from REST API. + '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 +538,17 @@ function ( array $element ): OD_Element { return $this->elements; } + /** + * Gets the source of the URL Metric. + * + * @since n.e.x.t + * + * @return 'visitor'|'user'|'synthetic'|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 a599098ebc..38300a01b8 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -65,6 +65,22 @@ const storageLockTimeSessionKey = 'odStorageLockTime'; */ const compressionDebounceWaitDuration = 1000; +/** + * Verification token for skipping the storage lock check while in priming mode. + * + * @see {detect} + * @type {?string} + */ +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. * @@ -73,6 +89,10 @@ const compressionDebounceWaitDuration = 1000; * @return {boolean} Whether storage is locked. */ function isStorageLocked( currentTime, storageLockTTL ) { + if ( primeModeVerificationToken ) { + return false; + } + if ( storageLockTTL === 0 ) { return false; } @@ -503,6 +523,114 @@ 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. + * + * @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, source ) { + const message = { + type: 'OD_PRIME_URL_METRICS_REQUEST_STATUS', + success: status.success, + ...( status.error && { error: status.error } ), + }; + + if ( + 'admin-dashboard' === source && + window.parent && + window.parent !== window + ) { + window.parent.postMessage( message, '*' ); + } else if ( 'priming-cli' === source ) { + document.dispatchEvent( + new CustomEvent( message.type, { + detail: { ...status }, + } ) + ); + } +} + +/** + * 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 @@ -613,6 +741,19 @@ export default async function detect( { return; } + // 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 ) { + const searchParams = new URLSearchParams( + window.location.hash.slice( 1 ) + ); + primeModeVerificationToken = searchParams.get( + 'odPrimeModeVerificationToken' + ); + primeModeSource = searchParams.get( 'odPrimeModeSource' ); + } + // Abort if the client already submitted a URL Metric for this URL and viewport group. const alreadySubmittedSessionStorageKey = await getAlreadySubmittedSessionStorageKey( @@ -622,6 +763,7 @@ export default async function detect( { logger ); if ( + ! primeModeVerificationToken && null !== alreadySubmittedSessionStorageKey && alreadySubmittedSessionStorageKey in sessionStorage ) { @@ -921,19 +1063,25 @@ export default async function detect( { debounceCompressUrlMetric(); // Wait for the page to be hidden. - await new Promise( ( resolve ) => { - win.addEventListener( 'pagehide', resolve, { once: true } ); - win.addEventListener( 'pageswap', resolve, { once: true } ); - doc.addEventListener( - 'visibilitychange', - () => { - if ( doc.visibilityState === 'hidden' ) { - // TODO: This will fire even when switching tabs. - resolve(); - } - }, - { once: true } - ); + await new Promise( async ( resolve ) => { + if ( ! primeModeVerificationToken ) { + win.addEventListener( 'pagehide', resolve, { once: true } ); + win.addEventListener( 'pageswap', resolve, { once: true } ); + doc.addEventListener( + 'visibilitychange', + () => { + if ( doc.visibilityState === 'hidden' ) { + // TODO: This will fire even when switching tabs. + resolve(); + } + }, + { once: true } + ); + } else { + await scrollToBottomOfPage(); + await forceCompressUrlMetric(); + resolve(); + } } ); // Only proceed with submitting the URL Metric if the viewport stayed the same size. Changing the viewport size (e.g. due @@ -1092,6 +1240,12 @@ export default async function detect( { ); } url.searchParams.set( 'hmac', urlMetricHMAC ); + if ( primeModeVerificationToken ) { + url.searchParams.set( + 'prime_url_metrics_verification_token', + primeModeVerificationToken + ); + } const headers = { 'Content-Type': 'application/json', @@ -1104,7 +1258,24 @@ export default async function detect( { method: 'POST', body: payloadBlob, headers, - keepalive: true, // This makes fetch() behave the same as navigator.sendBeacon(). + keepalive: primeModeVerificationToken ? false : true, // Setting keepalive to true makes fetch() behave the same as navigator.sendBeacon(). } ); - await fetch( request ); + + if ( ! primeModeVerificationToken ) { + await fetch( request ); + } else { + try { + const response = await fetch( request ); + if ( ! response.ok ) { + const errorData = await response.json(); + throw new Error( errorData.code ); + } + notifyStatus( { success: true }, primeModeSource ); + } catch ( err ) { + notifyStatus( + { success: false, error: err.message }, + primeModeSource + ); + } + } } diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index a36decccad..26fbcdbdc1 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -264,6 +264,31 @@ function_exists( 'gzdecode' ) && return $result; } +/** + * 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_Mode_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_VIEWPORTS_ROUTE, + $endpoint_controller->get_registration_args_prime_urls_viewports() + ); + 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/helper.php b/plugins/optimization-detective/helper.php index 75f985102a..406d89943d 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -240,3 +240,529 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string return $min_path; } + +/** + * Enqueues scripts for the URL priming in the admin area. + * + * @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 { + 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 + ); + } + + if ( + 'post.php' === $hook_suffix && + function_exists( 'get_current_screen' ) && + isset( $_GET['od_classic_editor_post_update_nonce'] ) && + 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'] + ) { + $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, + ) + ); + } + } + } +} + +/** + * Enqueues scripts for the URL priming in block editor. + * + * @since n.e.x.t + * @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 + ); +} + +/** + * 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. + * + * @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. + */ +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; + + // 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 ( $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 ( $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[ $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 = ( ( $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; + } + + // 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'] = $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. + $cursor['subtype_index'] = 0; + $cursor['page_number'] = 1; + $cursor['offset_within_page'] = 0; + } + + $cursor['provider_index'] = $provider_index; + ++$provider_index; + } // 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, + ); +} + +/** + * 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 + * + * @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. + * + * 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 + * + * @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( 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, + // 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, + '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; +} + +/** + * Gets the standard array of viewport based on real world device. + * + * @since n.e.x.t + * @access private + * + * @return array Array of viewports. + */ +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 ); +} + +/** + * 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 URL groups. + */ +function od_filter_priming_mode_batch_urls( array $urls ): array { + $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, + 'viewports' => $standard_viewports, + ); + 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_viewports = array(); + foreach ( $standard_viewports as $viewport ) { + if ( ! in_array( $viewport['width'], $existing_widths, true ) ) { + $missing_viewports[] = $viewport; + } + } + + if ( count( $missing_viewports ) > 0 ) { + $filtered_url_groups[] = array( + 'url' => $url, + 'viewports' => $missing_viewports, + ); + } + } + + return $filtered_url_groups; +} + +/** + * Determine whether to show the priming mode settings page. + * + * @since n.e.x.t + * + * @return bool True to display the settings page; false to hide it. + */ +function od_show_priming_mode_settings(): bool { + /** + * 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 ( true === $force_show ) { + return true; + } + + /** + * 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_show_priming_mode_settings_max_urls', 1000 ); + + $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.). + $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 ( $count >= $threshold ) { + $show = false; + break 3; + } + } + } + } + + set_transient( 'od_priming_mode_frontend_visible_url_count', $count, DAY_IN_SECONDS ); + + return $show; +} + +/** + * Generates the batch of URLs for priming URL Metrics. + * + * @since n.e.x.t + * @access private + * + * @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_priming_mode_batch( ?array $cursor ): array { + $default_cursor = array( + 'provider_index' => 0, + 'subtype_index' => 0, + 'page_number' => 1, + 'offset_within_page' => 0, + 'batch_size' => 10, + ); + + // 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_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_priming_mode_batch_cursor', $cursor ); + } + + $batch = array(); + $filtered_url_groups = array(); + $prevent_infinite_loop = 0; + while ( $prevent_infinite_loop < 100 ) { + if ( count( $filtered_url_groups ) > 0 ) { + break; + } + + $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_priming_mode_batch_cursor' ); + break; + } + $cursor = $batch['cursor']; + + ++$prevent_infinite_loop; + } + + return array( + 'urlGroups' => $filtered_url_groups, + 'cursor' => $batch['cursor'], + 'verificationToken' => od_get_priming_mode_verification_token(), + '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_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_priming_mode_verification_token', $verification_token, 30 * MINUTE_IN_SECONDS ); + } + return $verification_token; +} diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 584e88e745..7df99d41c0 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -24,7 +24,13 @@ 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' ); +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' ); add_action( 'rest_api_init', 'od_register_rest_url_metric_store_endpoint' ); add_filter( 'rest_pre_dispatch', 'od_decompress_rest_request_body', 10, 3 ); +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 74a1e0c1e1..952d663768 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'; @@ -116,6 +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-mode-endpoint.php'; require_once __DIR__ . '/storage/class-od-url-metric-store-request-context.php'; // Detection logic. @@ -134,5 +136,11 @@ static function ( string $version ): void { // Load site health checks. require_once __DIR__ . '/site-health.php'; + + // Load the settings page. + require_once __DIR__ . '/settings.php'; + + // Load WP-CLI commands. + require_once __DIR__ . '/storage/class-od-priming-mode-wp-cli.php'; } ); 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..dfd0bfefb8 --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics-block-editor.js @@ -0,0 +1,299 @@ +/** + * Helper script for the Priming URL Metrics in block editor. + */ +( function () { + // @ts-ignore + const { select, subscribe } = wp.data; + // @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 objects defining dimensions. + * + * @type {import("./types.ts").Viewport[]} + */ + let viewports = []; + + /** + * Queue of URL priming tasks generated from viewports. + * + * @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-priming-mode-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 ); + + /** + * 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 viewports. + * + * @return {Promise} The promise that resolves to void. + */ + async function processTasks() { + try { + isProcessing = true; + if ( 0 === viewports.length ) { + viewports = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-viewports', + method: 'GET', + } ); + } + + const permalink = select( 'core/editor' ).getPermalink(); + verificationToken = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-verification-token', + method: 'GET', + } ); + + currentTasks = viewports.map( ( viewport ) => ( { + url: permalink, + width: viewport.width, + height: viewport.height, + } ) ); + + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + } catch ( error ) { + log( error.message ); + if ( abortController.signal.aborted ) { + throw error; + } + } + currentTaskIndex++; + } + isProcessing = false; + } catch ( error ) { + isProcessing = false; + } + } + + /** + * Loads the iframe and waits for the message. + * + * @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. + */ + async function processTask( task, signal ) { + 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. + */ + async function handleMessage( event ) { + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + await cleanup(); + resolve(); + } else { + await cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } + } + } + + /** + * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. + */ + 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. + */ + 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, + } ); + } ); + } + + const timeoutId = setTimeout( async () => { + await cleanup(); + 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 = async () => { + await cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + const url = new URL( task.url ); + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( + verificationToken + ) }&odPrimeModeSource=admin-dashboard`; + + // Load the iframe. + iframe.src = url.toString(); + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + } ); + } + + /** + * Handles visibility change events to pause/resume processing when tab/window visibility changes. + */ + function handleVisibilityChange() { + if ( 'hidden' === document.visibilityState && isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } + processTasks(); + } + } + } + + /** + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload event + */ + function handleBeforeUnload( event ) { + if ( isProcessing ) { + event.preventDefault(); + } + } + + // 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 new file mode 100644 index 0000000000..d40a0f1be3 --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics-classic-editor.js @@ -0,0 +1,305 @@ +/** + * Helper script for the Priming URL Metrics in classic editor. + */ + +/* global odPrimeURLMetricsClassicEditor */ +( function ( odPrimeURLMetricsClassicEditor ) { + // @ts-ignore + 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 objects defining dimensions. + * + * @type {import("./types.ts").Viewport[]} + */ + let viewports = []; + + /** + * Queue of URL priming tasks generated from viewports. + * + * @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-priming-mode-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 ); + + /** + * 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 viewports. + * + * @return {Promise} The promise that resolves to void. + */ + async function processTasks() { + try { + isProcessing = true; + if ( 0 === viewports.length ) { + viewports = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-viewports', + method: 'GET', + } ); + } + + verificationToken = await apiFetch( { + path: '/optimization-detective/v1/priming-mode-verification-token', + method: 'GET', + } ); + + currentTasks = viewports.map( ( viewport ) => ( { + url: permalink, + width: viewport.width, + height: viewport.height, + } ) ); + + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + } catch ( error ) { + log( error ); + if ( abortController.signal.aborted ) { + throw error; + } + } + currentTaskIndex++; + } + isProcessing = false; + } catch ( error ) { + isProcessing = false; + } + } + + /** + * Loads the iframe and waits for the message. + * + * @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. + */ + function processTask( task, signal ) { + 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. + */ + async function handleMessage( event ) { + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + await cleanup(); + resolve(); + } else { + await cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } + } + } + + /** + * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. + */ + 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. + */ + 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, + } ); + } ); + } + + const timeoutId = setTimeout( async () => { + await cleanup(); + 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 = async () => { + await cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + const url = new URL( task.url ); + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( + verificationToken + ) }&odPrimeModeSource=admin-dashboard`; + + // Load the iframe. + iframe.src = url.toString(); + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + } ); + } + + /** + * Handles visibility change events to pause/resume processing when tab/window visibility changes. + */ + function handleVisibilityChange() { + if ( 'hidden' === document.visibilityState && isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } + processTasks(); + } + } + } + + /** + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload event + */ + function handleBeforeUnload( event ) { + if ( isProcessing && ! allowLeavingPage ) { + event.preventDefault(); + } + } + + // Attach event listeners. + + /** + * Primes the URL metrics for all viewports + * when the document is ready. + */ + 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 new file mode 100644 index 0000000000..1680d38e58 --- /dev/null +++ b/plugins/optimization-detective/prime-url-metrics.js @@ -0,0 +1,545 @@ +/** + * Helper script for Priming URL Metrics through WordPress admin dashboard. + */ +( function () { + // @ts-ignore + const { i18n, apiFetch } = wp; + const { __ } = i18n; + + /** + * Button element that toggles processing state. + * + * @type {HTMLButtonElement} + */ + const controlButton = document.querySelector( + 'button#od-priming-mode-control-button' + ); + + /** + * Progress bar element displaying current task completion progress. + * + * @type {HTMLProgressElement} + */ + const progressBar = document.querySelector( + 'progress#od-priming-mode-progress' + ); + + /** + * Container element displaying the status of URL metrics priming. + * + * @type {HTMLDivElement} + */ + const statusContainer = document.querySelector( + 'div#od-priming-mode-status-container' + ); + + /** + * Iframe used to load pages for priming URL metrics. + * + * @type {HTMLIFrameElement} + */ + const iframe = document.querySelector( 'iframe#od-priming-mode-iframe' ); + + /** + * Container that holds the iframe. + * + * @type {HTMLDivElement} + */ + const iframeContainer = document.querySelector( + 'div#od-priming-mode-iframe-container' + ); + + /** + * Element that displays the current batch number being processed. + * + * @type {HTMLSpanElement} + */ + const currentBatchElement = document.querySelector( + 'span#od-priming-mode-current-batch' + ); + + /** + * Element that displays the current task number being processed. + * + * @type {HTMLSpanElement} + */ + const currentTaskElement = document.querySelector( + 'span#od-priming-mode-current-task' + ); + + /** + * Element that displays the total number of tasks in the current batch. + * + * @type {HTMLSpanElement} + */ + const totalTasksInBatchElement = document.querySelector( + 'span#od-priming-mode-total-tasks-in-batch' + ); + + // Ensure all required elements are present. + if ( + ! controlButton || + ! progressBar || + ! statusContainer || + ! iframe || + ! iframeContainer || + ! currentBatchElement || + ! currentTaskElement || + ! totalTasksInBatchElement + ) { + return; + } + + /** + * 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; + + /** + * 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]'; + + /** + * ResizeObserver instance that adjusts iframe scale within container. + * + * @type {ResizeObserver} + */ + const iframeObserver = new ResizeObserver( fitIframe ); + iframeObserver.observe( 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 ); + } + + /** + * Toggles the processing state of the priming task. + */ + function toggleProcessing() { + if ( isProcessing ) { + // Pause processing. + isProcessing = false; + controlButton.textContent = __( + 'Resume', + 'optimization-detective' + ); + controlButton.classList.remove( 'updating-message' ); + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + } else { + // Start/resume processing. + isProcessing = true; + controlButton.textContent = __( 'Pause', 'optimization-detective' ); + controlButton.classList.add( 'updating-message' ); + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } + statusContainer.style.display = 'block'; + processBatches(); + } + } + + /** + * Processes batches of URL priming tasks. + */ + async function processBatches() { + try { + while ( isProcessing ) { + if ( ! currentBatch ) { + await prepareNextBatch(); + if ( ! isNextBatchAvailable ) { + break; + } + } + + await processCurrentBatch(); + + // Reset batch state if all tasks in the batch are processed. + if ( currentTaskIndex >= currentTasks.length ) { + cursor = currentBatch.cursor; + currentBatch = null; + currentTasks = []; + currentTaskIndex = 0; + totalTasksInBatchElement.textContent = '0'; + currentTaskElement.textContent = '0'; + } + } + } catch ( error ) { + if ( ! isTabHidden && ! abortController.signal.aborted ) { + isProcessing = false; + controlButton.textContent = __( + 'Click to retry', + 'optimization-detective' + ); + } + } finally { + if ( ! isNextBatchAvailable ) { + isProcessing = false; + controlButton.textContent = __( + 'Finished', + 'optimization-detective' + ); + controlButton.disabled = true; + controlButton.classList.remove( 'updating-message' ); + 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'; + } + } + } + + /** + * Prepares the next batch for processing. + */ + async function prepareNextBatch() { + controlButton.textContent = __( + 'Getting next batch…', + 'optimization-detective' + ); + currentBatch = await getBatch( cursor ); + + if ( ! currentBatch.urlGroups.length ) { + isNextBatchAvailable = false; + return; + } + + currentBatchNumber++; + currentBatchElement.textContent = currentBatchNumber.toString(); + + // Initialize batch state. + verificationToken = currentBatch.verificationToken; + isDebug = currentBatch.isDebug; + currentTasks = flattenBatchToTasks( currentBatch.urlGroups ); + currentTaskIndex = 0; + + // Update UI for new batch. + progressBar.max = currentTasks.length; + progressBar.value = currentTaskIndex + 1; + totalTasksInBatchElement.textContent = currentTasks.length.toString(); + currentTaskElement.textContent = ( currentTaskIndex + 1 ).toString(); + controlButton.textContent = __( 'Pause', 'optimization-detective' ); + } + + /** + * Processes tasks in the current batch. + */ + async function processCurrentBatch() { + while ( isProcessing && currentTaskIndex < currentTasks.length ) { + try { + await processTask( + currentTasks[ currentTaskIndex ], + abortController.signal + ); + } catch ( error ) { + log( error.message ); + if ( + error.message.includes( + 'priming_mode_verification_token_expired' + ) + ) { + verificationToken = await getVerificationToken(); + } else if ( abortController.signal.aborted ) { + throw error; + } + } + currentTaskIndex++; + progressBar.value = currentTaskIndex + 1; + currentTaskElement.textContent = ( + currentTaskIndex + 1 + ).toString(); + } + } + + /** + * 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. + */ + function flattenBatchToTasks( urlGroups ) { + return urlGroups.flatMap( ( urlGroup ) => + urlGroup.viewports.map( ( viewport ) => ( { + url: urlGroup.url, + width: viewport.width, + height: viewport.height, + } ) ) + ); + } + + /** + * Fetches the next batch of URLs for metric priming. + * + * @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 ) { + return await apiFetch( { + path: '/optimization-detective/v1/prime-urls', + method: 'POST', + data: { cursor: lastCursor }, + } ); + } + + /** + * 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. + * + * @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. + */ + function processTask( task, signal ) { + 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. + */ + async function handleMessage( event ) { + if ( + event.data && + event.data.type && + 'OD_PRIME_URL_METRICS_REQUEST_STATUS' === event.data.type + ) { + if ( event.data.success ) { + await cleanup(); + resolve(); + } else { + await cleanup(); + reject( + new Error( + event.data.error || 'URL Metric request failed' + ) + ); + } + } + } + + /** + * Handles the aborting of the task on abort signal. + * + * @return {Promise} The promise that resolves to void. + */ + 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. + */ + 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, + } ); + } ); + } + + const timeoutId = setTimeout( async () => { + await cleanup(); + 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 = async () => { + await cleanup(); + reject( new Error( 'Iframe failed to load' ) ); + }; + + const url = new URL( task.url ); + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( + verificationToken + ) }&odPrimeModeSource=admin-dashboard`; + + // Load the iframe. + iframe.src = url.toString(); + iframe.width = task.width.toString(); + iframe.height = task.height.toString(); + } ); + } + + /** + * 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; + + iframe.style.position = 'unset'; + iframe.style.transform = `scale(${ scale })`; + iframe.style.pointerEvents = 'auto'; + iframe.style.opacity = '1'; + iframe.style.zIndex = '9999'; + } + + /** + * Handles visibility change events to pause/resume processing when tab/window visibility changes. + */ + function handleVisibilityChange() { + if ( 'hidden' === document.visibilityState ) { + if ( isProcessing ) { + isProcessing = false; + isTabHidden = true; + if ( ! abortController.signal.aborted ) { + abortController.abort(); + } + controlButton.textContent = __( + 'Resume', + 'optimization-detective' + ); + } + } else if ( 'visible' === document.visibilityState && isTabHidden ) { + isTabHidden = false; + if ( ! isProcessing ) { + isProcessing = true; + controlButton.textContent = __( + 'Pause', + 'optimization-detective' + ); + if ( abortController.signal.aborted ) { + abortController = new AbortController(); + } + processBatches(); + } + } + } + + /** + * Handler for the beforeunload event to prevent accidental page navigation. + * + * @param {BeforeUnloadEvent} event - The beforeunload 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/index.js b/plugins/optimization-detective/priming-cli/index.js new file mode 100644 index 0000000000..7b2ca44440 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/index.js @@ -0,0 +1,400 @@ +#!/usr/bin/env node + +import { launch, Page } from 'puppeteer'; +import { program } from 'commander'; +import { execSync } from 'child_process'; +import ora from 'ora'; +import chalk from 'chalk'; + +program + .name( 'od-prime' ) + .description( 'CLI tool to prime URL metrics for Optimization Detective' ) + .parse( process.argv ); + +/** + * Instance of ora spinner for displaying status messages. + * + * @see {init} + * @see {getBatch} + * @see {checkEnvironment} + * @type {import('ora').Ora} + */ +const spinner = ora( 'Starting...' ).start(); + +/** + * Instance of AbortController to handle aborting tasks. + * + * @see {getBatch} + * @type {AbortController} + */ +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', () => { + 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 priming-mode 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 {?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 { + let command = 'wp od priming-mode 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 parsedBatch[ 0 ]; + } catch ( error ) { + spinner.fail( 'Error occurred while fetching batch: ' + error.message ); + abortController.abort(); + return null; + } +} + +/** + * 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. + * + * @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 ) => + urlGroup.viewports.map( ( viewport ) => ( { + url: urlGroup.url, + width: viewport.width, + height: viewport.height, + } ) ) + ); +} + +/** + * 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. + * @return {Promise} + */ +async function processTask( page, task, verificationToken, abortSignal ) { + return new Promise( async ( resolve, reject ) => { + /** + * Handles the abort event. + */ + function onAbort() { + 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, + } ); + + const url = new URL( task.url ); + url.hash = `odPrimeModeVerificationToken=${ encodeURIComponent( + verificationToken + ) }&odPrimeModeSource=priming-cli`; + + await page.goto( url.toString(), { + waitUntil: 'load', + timeout: 30000, + signal: abortSignal, + } ); + + await page.evaluate( () => { + 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 ); + + /** + * 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 } + ); + } + ); + } ); + + await cleanup(); + resolve(); + } catch ( error ) { + await cleanup(); + reject( error ); + } + } ); +} + +/** + * Init function to process all batches. + * @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 {import("./types.ts").URLBatchCursor} + */ + let cursor = null; + + /** + * 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 = null; + + // Process batches until no more are available. + while ( isNextBatchAvailable ) { + if ( signal.aborted ) { + break; + } + spinner.start( 'Fetching next batch' ); + const currentBatch = getBatch( cursor ); + + // If no URLs remain in the batch, finish processing. + if ( + null === currentBatch || + ! currentBatch.urlGroups || + currentBatch.urlGroups.length === 0 + ) { + isNextBatchAvailable = false; + break; + } + verificationToken = currentBatch.verificationToken; + currentBatchNumber++; + + spinner.text = `Batch ${ currentBatchNumber } fetched successfully.`; + + const currentTasks = flattenBatchToTasks( currentBatch.urlGroups ); + + // Process each task sequentially. + for ( let i = 0; i < currentTasks.length; i++ ) { + if ( signal.aborted ) { + break; + } + const task = currentTasks[ i ]; + + spinner.start( + `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 + ) }` + ); + try { + await processTask( + browserPage, + task, + verificationToken, + signal + ); + } catch ( error ) { + // 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; + } + + await browser.close(); + + if ( signal.aborted ) { + spinner.fail( chalk.red( 'Aborted.' ) ); + } else { + spinner.succeed( chalk.green( 'All tasks completed.' ) ); + } +} + +// Start the process. +if ( checkEnvironment() ) { + init(); +} 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..b3f717ddd1 --- /dev/null +++ b/plugins/optimization-detective/priming-cli/package-lock.json @@ -0,0 +1,1459 @@ +{ + "name": "@wordpress/performance-od-url-metric-collector", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@wordpress/performance-od-url-metric-collector", + "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..f7c76d049b --- /dev/null +++ b/plugins/optimization-detective/priming-cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wordpress/performance-od-url-metric-collector", + "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/types.ts b/plugins/optimization-detective/priming-cli/types.ts new file mode 100644 index 0000000000..d77db87051 --- /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 Viewport { + width: number; + height: number; +} + +export interface URLGroup { + url: string; + viewports: Viewport[]; +} + +export interface URLBatchResponse { + urlGroups: URLGroup[]; + cursor: URLBatchCursor | null; + verificationToken: string; + isDebug: boolean; +} + +export interface URLPrimingTask { + url: string; + width: number; + height: number; +} diff --git a/plugins/optimization-detective/settings.php b/plugins/optimization-detective/settings.php new file mode 100644 index 0000000000..357b24c42c --- /dev/null +++ b/plugins/optimization-detective/settings.php @@ -0,0 +1,106 @@ + +
+

+
+

+
+

+

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

+
+

+

+

+
+
+ +
+ +
+ +
+
+
+
+
+ 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' ); diff --git a/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php new file mode 100644 index 0000000000..506ca7da42 --- /dev/null +++ b/plugins/optimization-detective/storage/class-od-priming-mode-wp-cli.php @@ -0,0 +1,115 @@ +] + * : Index of the provider. + * --- + * 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=] + * : 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 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 + * + * @subcommand get-url-batch + * + * @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 { + $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, + ); + + $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_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 priming-mode', OD_Priming_Mode_WP_CLI::class ); 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 new file mode 100644 index 0000000000..075195727c --- /dev/null +++ b/plugins/optimization-detective/storage/class-od-rest-url-metrics-priming-mode-endpoint.php @@ -0,0 +1,162 @@ + '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 viewports 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_viewports(): array { + return array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_generate_viewports_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' ); + return new WP_REST_Response( od_generate_priming_mode_batch( $cursor ) ); + } + + /** + * 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_viewports_request(): WP_REST_Response { + return new WP_REST_Response( od_get_standard_viewports() ); + } + + /** + * 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 { + 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 50d1fff8a2..afd7b3cdd6 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,24 @@ 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 ( 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. if ( OD_Storage_Lock::is_locked() ) { @@ -215,6 +235,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/storage/data.php b/plugins/optimization-detective/storage/data.php index 7c06f4b24c..0f0cc7edc6 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -389,9 +389,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/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 24f10e3cf2..9eb0c6a47a 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 e131a66191..7841996ed4 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 97952c4ef1..b5ca39f081 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 61c0a8b670..64d07aca82 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 16780c57e7..4ade3b0cc8 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 f45dfcfd34..f3768013de 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 fe2985b156..2d5ca11ee2 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 c564bcab6a..03eb8d18b6 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 - + 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, + ), ); } diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index 39533aed39..4c303135ae 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 Viewport { + width: number; + height: number; +} + +export interface URLGroup { + url: string; + viewports: Viewport[]; +} + +export interface URLBatchResponse { + urlGroups: URLGroup[]; + cursor: URLBatchCursor | null; + verificationToken: string; + isDebug: boolean; +} + +export interface URLPrimingTask { + url: string; + width: number; + height: number; +} 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(); diff --git a/webpack.config.js b/webpack.config.js index 454ee4b499..2843c9de32 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -222,6 +222,18 @@ 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`, + }, + { + from: `${ destination }/prime-url-metrics-classic-editor.js`, + to: `${ destination }/prime-url-metrics-classic-editor.min.js`, + }, ], } ), // @ts-expect-error TS2351: WebpackBar is constructable when using require(), type definitions might be geared towards ESM.