Commit a1bfa6f0aae for woocommerce
commit a1bfa6f0aae5e0a8533ae4a6c5aec7772a31ae30
Author: Luigi Teschio <gigitux@gmail.com>
Date: Wed Jul 15 14:09:53 2026 +0200
Add legacy Select2 usage telemetry (#66245)
* Add legacy Select2 usage telemetry
* Add changelog entry for Select2 usage telemetry
* improve logic
* improve logic
* fix name test
* fix unit test
* improve logic
* improve logic
* remove page_url
* fix unit test
* fix unit test
* remove load track function
* store the relative path
* fix logic
* fix unit test
* store handles source path
diff --git a/plugins/woocommerce/changelog/add-select2-usage-metrics b/plugins/woocommerce/changelog/add-select2-usage-metrics
new file mode 100644
index 00000000000..13ada427246
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-select2-usage-metrics
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add telemetry for legacy Select2 script usage.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 33a18d52624..c808504d4d5 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -18,6 +18,7 @@ use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\LegacyAssets\LegacySelect2UsageTracker;
use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;
@@ -405,6 +406,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
+ $container->get( LegacySelect2UsageTracker::class )->register();
$container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
$container->get( EmailLogger::class )->register();
diff --git a/plugins/woocommerce/src/Internal/LegacyAssets/LegacySelect2UsageTracker.php b/plugins/woocommerce/src/Internal/LegacyAssets/LegacySelect2UsageTracker.php
new file mode 100644
index 00000000000..f2b8d8de0fd
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/LegacyAssets/LegacySelect2UsageTracker.php
@@ -0,0 +1,390 @@
+<?php
+/**
+ * Tracks usage of WooCommerce's bundled legacy Select2 handles.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\LegacyAssets;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WP_Scripts;
+use WC_Site_Tracking;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Tracks extensions that still depend on the legacy Select2 handles bundled by WooCommerce.
+ */
+class LegacySelect2UsageTracker implements RegisterHooksInterface {
+
+ public const EVENT_NAME = 'legacy_select2_usage_detected';
+
+ private const CONTEXT_ADMIN = 'admin';
+ private const CONTEXT_FRONTEND = 'frontend';
+
+ private const LEGACY_HANDLES = array(
+ 'select2',
+ 'wc-select2',
+ );
+
+ private const TRANSIENT_KEY_PREFIX = 'wc_legacy_select2_check_';
+
+ /**
+ * Register hook callbacks.
+ *
+ * @return void
+ *
+ * @since 11.0.0
+ */
+ public function register() {
+ if ( WC_Site_Tracking::is_tracking_enabled() ) {
+ add_action( 'admin_print_footer_scripts', array( $this, 'handle_admin_print_footer_scripts' ), PHP_INT_MAX );
+ add_action( 'wp_print_footer_scripts', array( $this, 'handle_wp_print_footer_scripts' ), PHP_INT_MAX );
+ }
+ }
+
+ /**
+ * Handle the admin_print_footer_scripts hook.
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_admin_print_footer_scripts(): void {
+ $this->track_usage( self::CONTEXT_ADMIN );
+ }
+
+ /**
+ * Handle the wp_print_footer_scripts hook.
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_wp_print_footer_scripts(): void {
+ $this->track_usage( self::CONTEXT_FRONTEND );
+ }
+
+ /**
+ * Build and record legacy Select2 usage events for the current request.
+ *
+ * @param string $context The request context.
+ * @return void
+ */
+ private function track_usage( string $context ): void {
+ if ( ! $this->is_legacy_select2_printed() ) {
+ return;
+ }
+
+ $event = $this->get_usage_event( $context );
+ if ( empty( $event ) ) {
+ return;
+ }
+
+ if ( $this->was_recently_checked( $event ) ) {
+ return;
+ }
+
+ $this->mark_recently_checked( $event );
+ $this->record_event( self::EVENT_NAME, $event );
+ }
+
+ /**
+ * Get a legacy Select2 usage event for the current script registry.
+ *
+ * @internal
+ *
+ * @param string $context The request context.
+ * @return array<string, string>
+ */
+ public function get_usage_event( string $context ): array {
+ $wp_scripts = wp_scripts();
+ if ( ! $wp_scripts instanceof WP_Scripts ) {
+ return array();
+ }
+
+ // `done` includes dependencies that WordPress printed while resolving the queue.
+ // Keep only queued handles so dependents identify scripts explicitly enqueued for the page.
+ $printed_queued_scripts = array_intersect( $wp_scripts->queue, $wp_scripts->done );
+
+ $handles = array();
+ $dependents = array();
+ $dependents_sources = array();
+ $plugins_path = wp_parse_url( plugins_url( '/' ), PHP_URL_PATH );
+
+ foreach ( $printed_queued_scripts as $handle ) {
+ $legacy_handles = $this->get_legacy_handles( $wp_scripts, $handle );
+
+ if ( empty( $legacy_handles ) ) {
+ continue;
+ }
+
+ $handles += array_fill_keys( $legacy_handles, true );
+ $dependents[ $handle ] = true;
+ $source = isset( $wp_scripts->registered[ $handle ] ) ? $wp_scripts->registered[ $handle ]->src : '';
+ $source = self::get_plugin_relative_source( $source, $plugins_path );
+
+ if ( '' !== $source ) {
+ $dependents_sources[] = $source;
+ }
+ }
+
+ if ( empty( $handles ) ) {
+ return array();
+ }
+
+ $handles = array_keys( $handles );
+ $dependents = array_keys( $dependents );
+ $dependents_sources = array_unique( $dependents_sources );
+ $handles_sources = array();
+
+ foreach ( $handles as $handle ) {
+ $source = self::get_legacy_handle_source( $wp_scripts, $handle );
+ $source = self::get_plugin_relative_source( $source, $plugins_path );
+
+ if ( '' !== $source ) {
+ $handles_sources[] = $source;
+ }
+ }
+
+ $handles_sources = array_unique( $handles_sources );
+
+ sort( $handles );
+ sort( $dependents );
+ sort( $dependents_sources );
+ sort( $handles_sources );
+
+ return array(
+ 'context' => $context,
+ 'page_type' => $this->get_current_page_type( $context ),
+ 'handles' => implode( ',', $handles ),
+ 'dependents' => implode( ',', $dependents ),
+ 'dependents_sources' => implode( ',', $dependents_sources ),
+ 'handles_sources' => implode( ',', $handles_sources ),
+ );
+ }
+
+ /**
+ * Record a Tracks event.
+ *
+ * @param string $event_name Event name.
+ * @param array<string, string> $properties Event properties.
+ * @return void
+ *
+ * @since 11.0.0
+ */
+ protected function record_event( string $event_name, array $properties ): void {
+ if ( ! class_exists( 'WC_Tracks' ) ) {
+ return;
+ }
+
+ \WC_Tracks::record_event( $event_name, $properties );
+ }
+
+ /**
+ * Whether this usage event was already checked recently.
+ *
+ * @param array<string, string> $event Usage event.
+ * @return bool
+ */
+ private function was_recently_checked( array $event ): bool {
+ return false !== get_transient( $this->get_transient_key( $event ) );
+ }
+
+ /**
+ * Mark this usage event as recently checked.
+ *
+ * @param array<string, string> $event Usage event.
+ * @return void
+ */
+ private function mark_recently_checked( array $event ): void {
+ set_transient( $this->get_transient_key( $event ), 'yes', WEEK_IN_SECONDS );
+ }
+
+ /**
+ * Check whether a legacy Select2 handle has been printed.
+ *
+ * @return bool
+ */
+ private function is_legacy_select2_printed(): bool {
+ foreach ( self::LEGACY_HANDLES as $handle ) {
+ if ( wp_script_is( $handle, 'done' ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the transient key for a usage event.
+ *
+ * @param array<string, string> $event Usage event.
+ * @return string
+ */
+ private function get_transient_key( array $event ): string {
+ ksort( $event );
+
+ $event_json = wp_json_encode( $event );
+
+ return self::TRANSIENT_KEY_PREFIX . md5( is_string( $event_json ) ? $event_json : '' );
+ }
+
+ /**
+ * Get legacy Select2 handles for a printed top-level handle.
+ *
+ * @param WP_Scripts $wp_scripts WordPress scripts registry.
+ * @param string $handle Script handle.
+ * @return array<int, string>
+ */
+ private function get_legacy_handles( WP_Scripts $wp_scripts, string $handle ): array {
+ $legacy_handles = array();
+
+ if ( in_array( $handle, self::LEGACY_HANDLES, true ) ) {
+ $legacy_handles[ $handle ] = true;
+ return array_keys( $legacy_handles );
+ }
+
+ if ( isset( $wp_scripts->registered[ $handle ] ) ) {
+ foreach ( $wp_scripts->registered[ $handle ]->deps as $dependency_handle ) {
+ if ( in_array( $dependency_handle, self::LEGACY_HANDLES, true ) ) {
+ $legacy_handles[ $dependency_handle ] = true;
+ }
+ }
+ }
+
+ return array_keys( $legacy_handles );
+ }
+
+ /**
+ * Get a script source relative to the plugins directory.
+ *
+ * @param string|false $source Script source.
+ * @param string|false|null $plugins_path Plugins directory URL path.
+ * @return string
+ */
+ private static function get_plugin_relative_source( $source, $plugins_path ): string {
+ if ( ! is_string( $source ) || '' === $source || ! is_string( $plugins_path ) || '' === $plugins_path ) {
+ return '';
+ }
+
+ $source_path = wp_parse_url( $source, PHP_URL_PATH );
+ if ( ! is_string( $source_path ) || ! str_starts_with( $source_path, $plugins_path ) ) {
+ return '';
+ }
+
+ return ltrim( substr( $source_path, strlen( $plugins_path ) ), '/' );
+ }
+
+ /**
+ * Get the source for a legacy Select2 handle.
+ *
+ * @param WP_Scripts $wp_scripts WordPress scripts registry.
+ * @param string $handle Script handle.
+ * @return string
+ */
+ private static function get_legacy_handle_source( WP_Scripts $wp_scripts, string $handle ): string {
+ $source_handle = 'select2' === $handle ? 'wc-select2' : $handle;
+ $registered_script = $wp_scripts->registered[ $source_handle ] ?? null;
+
+ if ( null === $registered_script ) {
+ return '';
+ }
+
+ return is_string( $registered_script->src ) ? $registered_script->src : '';
+ }
+
+ /**
+ * Get the current admin screen ID.
+ *
+ * @return string
+ */
+ private function get_current_screen_id(): string {
+ if ( ! function_exists( 'get_current_screen' ) ) {
+ return '';
+ }
+
+ $screen = get_current_screen();
+ return $screen ? (string) $screen->id : '';
+ }
+
+ /**
+ * Get the current page type.
+ *
+ * @param string $context The request context.
+ * @return string
+ */
+ private function get_current_page_type( string $context ): string {
+ if ( self::CONTEXT_ADMIN === $context ) {
+ return $this->get_current_screen_id();
+ }
+
+ if ( self::CONTEXT_FRONTEND === $context ) {
+ return $this->get_current_frontend_page_type();
+ }
+
+ return '';
+ }
+
+ /**
+ * Get the current frontend page type.
+ *
+ * @return string
+ */
+ private function get_current_frontend_page_type(): string {
+ if ( is_cart() ) {
+ return 'cart';
+ }
+
+ if ( is_checkout() ) {
+ return 'checkout';
+ }
+
+ if ( is_account_page() ) {
+ return 'my_account';
+ }
+
+ if ( is_shop() ) {
+ return 'shop';
+ }
+
+ if ( is_product() ) {
+ return 'product';
+ }
+
+ if ( is_product_category() ) {
+ return 'product_category';
+ }
+
+ if ( is_product_tag() ) {
+ return 'product_tag';
+ }
+
+ if ( is_product_taxonomy() ) {
+ return 'product_taxonomy';
+ }
+
+ if ( is_front_page() ) {
+ return 'front_page';
+ }
+
+ if ( is_home() ) {
+ return 'home';
+ }
+
+ if ( is_search() ) {
+ return 'search';
+ }
+
+ if ( is_archive() ) {
+ return 'archive';
+ }
+
+ if ( is_singular() ) {
+ return 'singular';
+ }
+
+ return 'other';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/LegacyAssets/LegacySelect2UsageTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/LegacyAssets/LegacySelect2UsageTrackerTest.php
new file mode 100644
index 00000000000..44621681346
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/LegacyAssets/LegacySelect2UsageTrackerTest.php
@@ -0,0 +1,455 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\LegacyAssets;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\LegacyAssets\LegacySelect2UsageTracker;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the LegacySelect2UsageTracker class.
+ */
+class LegacySelect2UsageTrackerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var LegacySelect2UsageTracker
+ */
+ private LegacySelect2UsageTracker $sut;
+
+ /**
+ * Script handles registered by the tests.
+ *
+ * @var array<int, string>
+ */
+ private array $test_script_handles = array(
+ 'my-extension-admin',
+ 'my-extension-footer',
+ 'intermediate-select2-wrapper',
+ 'my-extension-transitive',
+ );
+
+ /**
+ * Original request URI.
+ *
+ * @var string|null
+ */
+ private ?string $original_request_uri = null;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Test fixture preserves the raw request URI for restoration.
+ $this->original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : null;
+ $_SERVER['REQUEST_URI'] = '/';
+ $this->reset_scripts();
+ $this->register_legacy_select2_scripts();
+ $this->sut = new LegacySelect2UsageTracker();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->reset_scripts();
+ set_current_screen( 'front' );
+
+ if ( null === $this->original_request_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $this->original_request_uri;
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should track a plugin-owned header dependency on select2.
+ */
+ public function test_tracks_plugin_owned_header_dependency_on_select2(): void {
+ set_current_screen( 'woocommerce_page_wc-settings' );
+ $_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=wc-settings';
+ wp_register_script(
+ 'my-extension-admin',
+ $this->get_my_extension_asset_url( 'admin.js' ),
+ array( 'select2' ),
+ '1.0.0',
+ false
+ );
+ wp_enqueue_script( 'my-extension-admin' );
+
+ $this->print_script_group( 0 );
+
+ $this->assertSame(
+ array(
+ 'context' => 'admin',
+ 'page_type' => 'woocommerce_page_wc-settings',
+ 'handles' => 'select2',
+ 'dependents' => 'my-extension-admin',
+ 'dependents_sources' => 'my-extension/assets/admin.js',
+ 'handles_sources' => $this->get_expected_wc_select2_source(),
+ ),
+ $this->sut->get_usage_event( 'admin' ),
+ 'Admin usage should include only the expected event properties.'
+ );
+ }
+
+ /**
+ * @testdox Should track a plugin-owned footer dependency on wc-select2.
+ */
+ public function test_tracks_plugin_owned_footer_dependency_on_wc_select2(): void {
+ wp_register_script(
+ 'my-extension-footer',
+ $this->get_my_extension_asset_url( 'footer.js' ),
+ array( 'wc-select2' ),
+ '1.0.0',
+ true
+ );
+ wp_enqueue_script( 'my-extension-footer' );
+
+ $this->print_script_group( 1 );
+
+ $this->assertSame(
+ array(
+ 'context' => 'frontend',
+ 'page_type' => $this->get_expected_frontend_page_type(),
+ 'handles' => 'wc-select2',
+ 'dependents' => 'my-extension-footer',
+ 'dependents_sources' => 'my-extension/assets/footer.js',
+ 'handles_sources' => $this->get_expected_wc_select2_source(),
+ ),
+ $this->sut->get_usage_event( 'frontend' ),
+ 'Frontend usage should report the direct wc-select2 handle.'
+ );
+ }
+
+ /**
+ * @testdox Should not track transitive dependencies reaching select2.
+ */
+ public function test_does_not_track_transitive_dependency_reaching_select2(): void {
+ wp_register_script(
+ 'intermediate-select2-wrapper',
+ $this->get_my_extension_asset_url( 'wrapper.js' ),
+ array( 'select2' ),
+ '1.0.0',
+ true
+ );
+ wp_register_script(
+ 'my-extension-transitive',
+ $this->get_my_extension_asset_url( 'transitive.js' ),
+ array( 'intermediate-select2-wrapper' ),
+ '1.0.0',
+ true
+ );
+ wp_enqueue_script( 'my-extension-transitive' );
+
+ $this->print_script_group( 1 );
+
+ $this->assertSame( array(), $this->sut->get_usage_event( 'frontend' ), 'Transitive dependencies are intentionally ignored to keep tracking bounded.' );
+ }
+
+ /**
+ * @testdox Should track direct legacy handle enqueue without an attributable plugin.
+ */
+ public function test_tracks_direct_legacy_handle_enqueue_without_attributable_plugin(): void {
+ wp_enqueue_script( 'select2' );
+
+ $this->print_script_group( 1 );
+
+ $this->assertSame(
+ array(
+ 'context' => 'frontend',
+ 'page_type' => $this->get_expected_frontend_page_type(),
+ 'handles' => 'select2',
+ 'dependents' => 'select2',
+ 'dependents_sources' => '',
+ 'handles_sources' => $this->get_expected_wc_select2_source(),
+ ),
+ $this->sut->get_usage_event( 'frontend' ),
+ 'Direct Select2 enqueue should report the requested handle.'
+ );
+ }
+
+ /**
+ * @testdox Should not track selectWoo usage alone.
+ */
+ public function test_does_not_track_selectwoo_alone(): void {
+ wp_register_script(
+ 'selectWoo',
+ plugins_url( 'woocommerce/assets/js/selectWoo/selectWoo.full.js' ),
+ array( 'jquery' ),
+ '1.0.0',
+ true
+ );
+ wp_enqueue_script( 'selectWoo' );
+
+ $this->print_script_group( 1 );
+
+ $this->assertSame( array(), $this->sut->get_usage_event( 'frontend' ), 'selectWoo alone should not trigger legacy Select2 usage tracking.' );
+ }
+
+ /**
+ * @testdox Should not track queued dependencies before they are printed.
+ */
+ public function test_does_not_track_queued_dependencies_before_they_are_printed(): void {
+ set_current_screen( 'woocommerce_page_wc-settings' );
+ wp_register_script(
+ 'my-extension-admin',
+ $this->get_my_extension_asset_url( 'admin.js' ),
+ array( 'select2' ),
+ '1.0.0',
+ false
+ );
+ wp_enqueue_script( 'my-extension-admin' );
+
+ $this->assertSame( array(), $this->sut->get_usage_event( 'admin' ), 'Queued scripts should not be reported before WordPress prints them.' );
+ }
+
+ /**
+ * @testdox Should report footer dependencies after footer scripts are printed.
+ */
+ public function test_reports_footer_dependencies_after_footer_scripts_are_printed(): void {
+ wp_register_script(
+ 'my-extension-footer',
+ $this->get_my_extension_asset_url( 'footer.js' ),
+ array( 'wc-select2' ),
+ '1.0.0',
+ true
+ );
+ wp_enqueue_script( 'my-extension-footer' );
+
+ $this->print_script_group( 0 );
+
+ $this->assertSame( array(), $this->sut->get_usage_event( 'frontend' ), 'Footer usage should not be reported before footer scripts are printed.' );
+
+ $this->print_script_group( 1 );
+
+ $this->assertSame(
+ array(
+ 'context' => 'frontend',
+ 'page_type' => $this->get_expected_frontend_page_type(),
+ 'handles' => 'wc-select2',
+ 'dependents' => 'my-extension-footer',
+ 'dependents_sources' => 'my-extension/assets/footer.js',
+ 'handles_sources' => $this->get_expected_wc_select2_source(),
+ ),
+ $this->sut->get_usage_event( 'frontend' ),
+ 'Footer dependencies should be reported after footer scripts are printed.'
+ );
+ }
+
+ /**
+ * @testdox Should not track registered plugin dependencies that are not enqueued.
+ */
+ public function test_does_not_track_registered_plugin_dependencies_that_are_not_enqueued(): void {
+ wp_register_script(
+ 'my-extension-footer',
+ $this->get_my_extension_asset_url( 'footer.js' ),
+ array( 'wc-select2' ),
+ '1.0.0',
+ true
+ );
+
+ $this->assertSame( array(), $this->sut->get_usage_event( 'frontend' ), 'Registered scripts should not be reported until they are enqueued.' );
+ }
+
+ /**
+ * @testdox Should record each detected usage event only once per week.
+ */
+ public function test_records_each_detected_usage_event_only_once_per_week(): void {
+ $original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : null;
+ $_SERVER['REQUEST_URI'] = '/shop/?filter=featured';
+ $event = array(
+ 'context' => 'frontend',
+ 'page_type' => $this->get_expected_frontend_page_type(),
+ 'handles' => 'wc-select2',
+ 'dependents' => 'my-extension-footer',
+ 'dependents_sources' => 'my-extension/assets/footer.js',
+ 'handles_sources' => $this->get_expected_wc_select2_source(),
+ );
+
+ $this->delete_usage_event_transient( $event );
+
+ $sut = new class() extends LegacySelect2UsageTracker {
+ /**
+ * The number of times the usage event was built.
+ *
+ * @var int
+ */
+ public int $usage_event_calls = 0;
+
+ /**
+ * Recorded Tracks events.
+ *
+ * @var array<int, array{name: string, properties: array<string, string>}>
+ */
+ public array $recorded_events = array();
+
+ /**
+ * Get a legacy Select2 usage event for the current script registry.
+ *
+ * @internal
+ *
+ * @param string $context The request context.
+ * @return array<string, string>
+ */
+ public function get_usage_event( string $context ): array {
+ ++$this->usage_event_calls;
+
+ return parent::get_usage_event( $context );
+ }
+
+ /**
+ * Record a Tracks event.
+ *
+ * @param string $event_name Event name.
+ * @param array<string, string> $properties Event properties.
+ * @return void
+ */
+ protected function record_event( string $event_name, array $properties ): void {
+ $this->recorded_events[] = array(
+ 'name' => $event_name,
+ 'properties' => $properties,
+ );
+ }
+ };
+
+ wp_register_script(
+ 'my-extension-footer',
+ $this->get_my_extension_asset_url( 'footer.js' ),
+ array( 'wc-select2' ),
+ '1.0.0',
+ true
+ );
+ wp_enqueue_script( 'my-extension-footer' );
+
+ $this->print_script_group( 1 );
+ $sut->handle_wp_print_footer_scripts();
+
+ $_SERVER['REQUEST_URI'] = '/product/hoodie/';
+
+ $sut->handle_wp_print_footer_scripts();
+
+ $this->assertSame(
+ array(
+ array(
+ 'name' => LegacySelect2UsageTracker::EVENT_NAME,
+ 'properties' => $event,
+ ),
+ ),
+ $sut->recorded_events,
+ 'Repeated detections of the same legacy Select2 usage event should be rate limited.'
+ );
+ $this->assertSame( 2, $sut->usage_event_calls, 'Each detection should scan the script registry before rate limiting the exact usage event.' );
+
+ $this->delete_usage_event_transient( $event );
+
+ if ( null === $original_request_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $original_request_uri;
+ }
+ }
+
+ /**
+ * Register WooCommerce's legacy Select2 handles.
+ */
+ private function register_legacy_select2_scripts(): void {
+ if ( ! class_exists( 'WC_Admin_Assets' ) && defined( 'WC_ABSPATH' ) ) {
+ include_once WC_ABSPATH . 'includes/admin/class-wc-admin-assets.php';
+ }
+
+ $admin_assets_reflection = new \ReflectionClass( \WC_Admin_Assets::class );
+ $admin_assets = $admin_assets_reflection->newInstanceWithoutConstructor();
+ $admin_assets->register_scripts();
+ }
+
+ /**
+ * Reset the global scripts registry runtime state.
+ */
+ private function reset_scripts(): void {
+ $wp_scripts = wp_scripts();
+
+ $wp_scripts->queue = array();
+ $wp_scripts->to_do = array();
+ $wp_scripts->done = array();
+ $wp_scripts->groups = array();
+ $wp_scripts->in_footer = array();
+ $wp_scripts->args = array();
+
+ foreach ( $this->test_script_handles as $handle ) {
+ wp_deregister_script( $handle );
+ }
+ }
+
+ /**
+ * Print queued scripts for a script group while discarding script output.
+ *
+ * @param int $group Script group. 0 for header, 1 for footer.
+ */
+ private function print_script_group( int $group ): void {
+ ob_start();
+
+ if ( 0 === $group ) {
+ wp_scripts()->do_head_items();
+ } else {
+ wp_scripts()->do_footer_items();
+ }
+
+ ob_end_clean();
+ }
+
+ /**
+ * Get a plugin fixture asset URL.
+ *
+ * @param string $asset Asset filename.
+ * @return string
+ */
+ private function get_my_extension_asset_url( string $asset ): string {
+ return plugins_url( 'my-extension/assets/' . $asset );
+ }
+
+ /**
+ * Get the frontend page type expected for the current PHPUnit process.
+ *
+ * Some tests render cart flows that define WOOCOMMERCE_CART. Since PHP
+ * constants cannot be undefined, this test must accept the inherited cart
+ * page type when it runs later in the same process.
+ *
+ * @return string
+ */
+ private function get_expected_frontend_page_type(): string {
+ return Constants::is_defined( 'WOOCOMMERCE_CART' ) ? 'cart' : 'other';
+ }
+
+ /**
+ * Get the expected WooCommerce Select2 source path.
+ *
+ * @return string
+ */
+ private function get_expected_wc_select2_source(): string {
+ $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
+
+ return 'woocommerce/assets/js/select2/select2.full' . $suffix . '.js';
+ }
+
+ /**
+ * Delete the transient used to rate limit a usage event.
+ *
+ * @param array<string, string> $event Usage event.
+ */
+ private function delete_usage_event_transient( array $event ): void {
+ ksort( $event );
+
+ $event_json = wp_json_encode( $event );
+
+ delete_transient(
+ 'wc_legacy_select2_check_' . md5( is_string( $event_json ) ? $event_json : '' )
+ );
+ }
+}