Commit 33439e27160 for woocommerce

commit 33439e27160f1e8535aa7a5b02520057ca390653
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Thu Nov 5 23:17:27 2026 +0100

    Refresh WooCommerce admin icons for WordPress 7.2 (#70621)

    * Refresh admin icons for WordPress 7.2

    * Add changelog entry for admin icons

    * Strengthen admin icon regression tests

    * Restore the Products box icon

    * Align admin icon since tag with WC 11.3

    * Restore the Products tag icon

    Revert the box-icon change to follow the design-approved choice in the prototype.

    * Explain icon API checks for PHPStan

    * Explain admin menu global mutation

    * Require the icon API in legacy icon tests

    * Require the icon API in missing icon tests

    * Import the admin menu icon service

diff --git a/plugins/woocommerce/changelog/update-wooplug-7938-admin-icons b/plugins/woocommerce/changelog/update-wooplug-7938-admin-icons
new file mode 100644
index 00000000000..33338a4056b
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-wooplug-7938-admin-icons
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Refresh WooCommerce admin menu icons to match WordPress 7.2 while keeping existing icons on older versions.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index fae388bac96..1a8ec8df8ae 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Internal\Admin\LegacyReportsMenu;
 use Automattic\WooCommerce\Internal\Admin\Marketplace;
+use Automattic\WooCommerce\Internal\Admin\MenuIcons;
 use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
 use Automattic\WooCommerce\Internal\Admin\Orders\PageController as Custom_Orders_PageController;
 use Automattic\WooCommerce\Internal\Admin\Logging\PageController as LoggingPageController;
@@ -87,7 +88,7 @@ class WC_Admin_Menus {
 	public function admin_menu() {
 		global $menu, $admin_page_hooks;

-		$woocommerce_icon = 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDg1LjkgNDcuNiI+CjxwYXRoIGZpbGw9IiNhMmFhYjIiIGQ9Ik03Ny40LDAuMWMtNC4zLDAtNy4xLDEuNC05LjYsNi4xTDU2LjQsMjcuN1Y4LjZjMC01LjctMi43LTguNS03LjctOC41cy03LjEsMS43LTkuNiw2LjVMMjguMywyNy43VjguOAoJYzAtNi4xLTIuNS04LjctOC42LTguN0g3LjNDMi42LDAuMSwwLDIuMywwLDYuM3MyLjUsNi40LDcuMSw2LjRoNS4xdjI0LjFjMCw2LjgsNC42LDEwLjgsMTEuMiwxMC44UzMzLDQ1LDM2LjMsMzguOWw3LjItMTMuNXYxMS40CgljMCw2LjcsNC40LDEwLjgsMTEuMSwxMC44czkuMi0yLjMsMTMtOC43bDE2LjYtMjhjMy42LTYuMSwxLjEtMTAuOC02LjktMTAuOEM3Ny4zLDAuMSw3Ny4zLDAuMSw3Ny40LDAuMXoiLz4KPC9zdmc+Cg==';
+		$woocommerce_icon = wc_get_container()->get( MenuIcons::class )->get_icon( 'woocommerce/logo', 'data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDg1LjkgNDcuNiI+CjxwYXRoIGZpbGw9IiNhMmFhYjIiIGQ9Ik03Ny40LDAuMWMtNC4zLDAtNy4xLDEuNC05LjYsNi4xTDU2LjQsMjcuN1Y4LjZjMC01LjctMi43LTguNS03LjctOC41cy03LjEsMS43LTkuNiw2LjVMMjguMywyNy43VjguOAoJYzAtNi4xLTIuNS04LjctOC42LTguN0g3LjNDMi42LDAuMSwwLDIuMywwLDYuM3MyLjUsNi40LDcuMSw2LjRoNS4xdjI0LjFjMCw2LjgsNC42LDEwLjgsMTEuMiwxMC44UzMzLDQ1LDM2LjMsMzguOWw3LjItMTMuNXYxMS40CgljMCw2LjcsNC40LDEwLjgsMTEuMSwxMC44czkuMi0yLjMsMTMtOC43bDE2LjYtMjhjMy42LTYuMSwxLjEtMTAuOC02LjktMTAuOEM3Ny4zLDAuMSw3Ny4zLDAuMSw3Ny40LDAuMXoiLz4KPC9zdmc+Cg==' );

 		if ( self::can_view_woocommerce_menu_item() ) {
 			$menu[] = array( '', 'read', 'separator-woocommerce', '', 'wp-menu-separator woocommerce' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WordPress uses the $menu global for admin menu registration.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index f4e7b25c0ed..ed5fcd99d31 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -38,6 +38,7 @@ use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
 use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
 use Automattic\WooCommerce\Internal\Email\EmailLogger;
 use Automattic\WooCommerce\Internal\Admin\Marketplace;
+use Automattic\WooCommerce\Internal\Admin\MenuIcons;
 use Automattic\WooCommerce\Internal\Admin\OrderMilestoneEasterEgg;
 use Automattic\WooCommerce\Internal\Admin\Screens\AdminScreens;
 use Automattic\WooCommerce\Internal\Admin\Screens\Settings\ChequeSettings;
@@ -422,6 +423,7 @@ final class WooCommerce {
 		$container->get( SettingsScreen::class );
 		$container->get( AbilitiesTracking::class );
 		$container->get( OpenGraph::class );
+		$container->get( MenuIcons::class );

 		/**
 		 * These classes have a register method for attaching hooks.
diff --git a/plugins/woocommerce/src/Internal/Admin/Marketing.php b/plugins/woocommerce/src/Internal/Admin/Marketing.php
index 450ef891423..fb1f5effeee 100644
--- a/plugins/woocommerce/src/Internal/Admin/Marketing.php
+++ b/plugins/woocommerce/src/Internal/Admin/Marketing.php
@@ -77,7 +77,7 @@ class Marketing {
 				'manage_woocommerce',
 				'woocommerce-marketing',
 				null,
-				'dashicons-megaphone',
+				wc_get_container()->get( MenuIcons::class )->get_icon( 'woocommerce/megaphone', 'dashicons-megaphone' ),
 				58
 			);
 		}
diff --git a/plugins/woocommerce/src/Internal/Admin/MenuIcons.php b/plugins/woocommerce/src/Internal/Admin/MenuIcons.php
new file mode 100644
index 00000000000..b1d9764766b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/MenuIcons.php
@@ -0,0 +1,125 @@
+<?php
+/**
+ * WooCommerce admin menu icons.
+ *
+ * @package WooCommerce\Internal\Admin
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin;
+
+/**
+ * Uses registered SVG icons in the WordPress 7.2 admin menu.
+ *
+ * @internal
+ */
+final class MenuIcons {
+
+	/**
+	 * Icons not provided by the WordPress admin icon collection.
+	 *
+	 * The logo uses an 18px mark on a 24px grid. The megaphone comes from @wordpress/icons.
+	 */
+	private const ICONS = array(
+		'logo'      => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19.22 7.03c-0.9 0 -1.49 0.29 -2.01 1.28L14.82 12.82V8.81c0 -1.19 -0.57 -1.78 -1.61 -1.78s-1.49 0.36 -2.01 1.36L8.93 12.82V8.86c0 -1.28 -0.52 -1.82 -1.8 -1.82H4.53C3.54 7.03 3 7.49 3 8.33s0.52 1.34 1.49 1.34h1.07v5.05c0 1.42 0.96 2.26 2.35 2.26S9.92 16.44 10.61 15.16l1.51 -2.83v2.39c0 1.4 0.92 2.26 2.33 2.26s1.93 -0.48 2.72 -1.82l3.48 -5.87c0.75 -1.28 0.23 -2.26 -1.45 -2.26C19.2 7.03 19.2 7.03 19.22 7.03z"/></svg>',
+		'megaphone' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="fill: none" stroke="currentColor" stroke-width="1.5"><path d="M13.4999 14.0178C13.4903 15.6665 12.1509 17 10.5 17C8.84315 17 7.5 15.6569 7.5 14C7.5 13.6412 7.56297 13.2972 7.67847 12.9783M7.67847 12.9783L13.4999 14.0178L19 15V7L5 10V12.5L7.67847 12.9783Z"/></svg>',
+	);
+
+	/**
+	 * Attach admin menu hooks.
+	 *
+	 * @internal
+	 */
+	public function __construct() {
+		add_action( 'admin_menu', array( $this, 'handle_admin_menu_registration' ), 0 );
+		add_action( 'admin_menu', array( $this, 'handle_admin_menu' ), 30 );
+	}
+
+	/**
+	 * Register WooCommerce's private icon collection before menu registration.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function handle_admin_menu_registration() {
+		// Keep explicit API checks until the WordPress PHPStan stubs include the icon API.
+		if ( ! $this->is_supported() || ! function_exists( 'wp_register_icon_collection' ) || ! function_exists( 'wp_register_icon' ) || ! class_exists( '\WP_Icon_Collections_Registry' ) || ! class_exists( '\WP_Icons_Registry' ) ) {
+			return;
+		}
+
+		if ( ! \WP_Icon_Collections_Registry::get_instance()->is_registered( 'woocommerce' ) ) {
+			wp_register_icon_collection(
+				'woocommerce',
+				array(
+					'label'  => __( 'WooCommerce', 'woocommerce' ),
+					'public' => false,
+				)
+			);
+		}
+
+		$labels = array(
+			'logo'      => __( 'WooCommerce', 'woocommerce' ),
+			'megaphone' => __( 'Megaphone', 'woocommerce' ),
+		);
+		foreach ( self::ICONS as $name => $content ) {
+			if ( ! \WP_Icons_Registry::get_instance()->is_registered( 'woocommerce/' . $name ) ) {
+				wp_register_icon(
+					'woocommerce/' . $name,
+					array(
+						'label'   => $labels[ $name ],
+						'content' => $content,
+					)
+				);
+			}
+		}
+	}
+
+	/**
+	 * Return a registered menu icon, retaining the legacy icon on older WordPress versions.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $icon_name Namespaced icon name.
+	 * @param string $fallback  Legacy Dashicon class or SVG data URI.
+	 * @return string
+	 */
+	public function get_icon( string $icon_name, string $fallback ): string {
+		return $this->is_supported() && class_exists( '\WP_Icons_Registry' ) && \WP_Icons_Registry::get_instance()->is_registered( $icon_name ) ? $icon_name : $fallback;
+	}
+
+	/**
+	 * Update post type menu icons without changing their REST API metadata or extension overrides.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function handle_admin_menu() {
+		global $menu;
+
+		if ( ! $this->is_supported() || ! is_array( $menu ) ) {
+			return;
+		}
+
+		$icons = array(
+			'edit.php?post_type=product'    => array( 'dashicons-archive', 'core-admin/tag' ),
+			'edit.php?post_type=shop_order' => array( 'dashicons-admin-post', 'core-admin/receipt' ),
+		);
+		foreach ( $menu as $index => $item ) {
+			if ( isset( $item[2], $item[6], $icons[ $item[2] ] ) && $icons[ $item[2] ][0] === $item[6] ) {
+				$menu[ $index ][6] = $this->get_icon( $icons[ $item[2] ][1], $item[6] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WordPress reads the $menu global when rendering the admin menu.
+			}
+		}
+	}
+
+	/**
+	 * Check menu support as well as API availability, since Gutenberg can provide APIs on older WordPress.
+	 *
+	 * @return bool
+	 */
+	private function is_supported(): bool {
+		return version_compare( get_bloginfo( 'version' ), '7.2-alpha', '>=' ) && function_exists( 'wp_is_valid_icon_name' );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsController.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsController.php
index e9cac7ce279..100cde1eb56 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsController.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsController.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\Admin\Settings;
 use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
 use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
+use Automattic\WooCommerce\Internal\Admin\MenuIcons;
 use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsService;
 use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
 use Throwable;
@@ -70,7 +71,7 @@ class PaymentsController {
 		}

 		$menu_title = esc_html__( 'Payments', 'woocommerce' );
-		$menu_icon  = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NTIiIGhlaWdodD0iNjg0Ij48cGF0aCBmaWxsPSIjYTJhYWIyIiBkPSJNODIgODZ2NTEyaDY4NFY4NlptMCA1OThjLTQ4IDAtODQtMzgtODQtODZWODZDLTIgMzggMzQgMCA4MiAwaDY4NGM0OCAwIDg0IDM4IDg0IDg2djUxMmMwIDQ4LTM2IDg2LTg0IDg2em0zODQtNTU2djQ0aDg2djg0SDM4MnY0NGgxMjhjMjQgMCA0MiAxOCA0MiA0MnYxMjhjMCAyNC0xOCA0Mi00MiA0MmgtNDR2NDRoLTg0di00NGgtODZ2LTg0aDE3MHYtNDRIMzM4Yy0yNCAwLTQyLTE4LTQyLTQyVjIxNGMwLTI0IDE4LTQyIDQyLTQyaDQ0di00NHoiLz48L3N2Zz4=';
+		$menu_icon  = wc_get_container()->get( MenuIcons::class )->get_icon( 'core-admin/payment', 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NTIiIGhlaWdodD0iNjg0Ij48cGF0aCBmaWxsPSIjYTJhYWIyIiBkPSJNODIgODZ2NTEyaDY4NFY4NlptMCA1OThjLTQ4IDAtODQtMzgtODQtODZWODZDLTIgMzggMzQgMCA4MiAwaDY4NGM0OCAwIDg0IDM4IDg0IDg2djUxMmMwIDQ4LTM2IDg2LTg0IDg2em0zODQtNTU2djQ0aDg2djg0SDM4MnY0NGgxMjhjMjQgMCA0MiAxOCA0MiA0MnYxMjhjMCAyNC0xOCA0Mi00MiA0MmgtNDR2NDRoLTg0di00NGgtODZ2LTg0aDE3MHYtNDRIMzM4Yy0yNCAwLTQyLTE4LTQyLTQyVjIxNGMwLTI0IDE4LTQyIDQyLTQyaDQ0di00NHoiLz48L3N2Zz4=' );
 		// Link to the Payments settings page.
 		$menu_path = 'admin.php?page=wc-settings&tab=checkout&from=' . Payments::FROM_PAYMENTS_MENU_ITEM;

diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/MenuIconsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/MenuIconsTest.php
new file mode 100644
index 00000000000..a1cf7031025
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/MenuIconsTest.php
@@ -0,0 +1,164 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\MenuIcons;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for WooCommerce admin menu icons.
+ */
+class MenuIconsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var MenuIcons
+	 */
+	private $sut;
+
+	/**
+	 * Globals changed by these tests.
+	 *
+	 * @var array
+	 */
+	private $globals_backup;
+
+	/**
+	 * Whether the test owns the WooCommerce icon collection.
+	 *
+	 * @var bool
+	 */
+	private $owns_collection;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->globals_backup  = array(
+			'menu'       => $GLOBALS['menu'] ?? null,
+			'wp_version' => $GLOBALS['wp_version'],
+		);
+		$this->sut             = wc_get_container()->get( MenuIcons::class );
+		$this->owns_collection = class_exists( '\WP_Icon_Collections_Registry' ) && ! \WP_Icon_Collections_Registry::get_instance()->is_registered( 'woocommerce' );
+	}
+
+	/**
+	 * Restore globals that WordPress does not reset.
+	 */
+	public function tearDown(): void {
+		try {
+			if ( $this->owns_collection && \WP_Icon_Collections_Registry::get_instance()->is_registered( 'woocommerce' ) ) {
+				foreach ( array( 'woocommerce/logo', 'woocommerce/megaphone' ) as $name ) {
+					if ( \WP_Icons_Registry::get_instance()->is_registered( $name ) ) {
+						wp_unregister_icon( $name );
+					}
+				}
+				wp_unregister_icon_collection( 'woocommerce' );
+			}
+			foreach ( $this->globals_backup as $name => $value ) {
+				$GLOBALS[ $name ] = $value; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore test globals.
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should keep legacy icons on WordPress 7.1 and earlier even when the icon API is available.
+	 * @testWith ["6.9.4"]
+	 *           ["7.0"]
+	 *           ["7.1.3"]
+	 *
+	 * @param string $version WordPress version.
+	 */
+	public function test_legacy_icons( string $version ): void {
+		$this->require_icon_api();
+		$GLOBALS['wp_version'] = $version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate supported WordPress versions.
+
+		$this->assertSame( 'dashicons-archive', $this->sut->get_icon( 'core-admin/tag', 'dashicons-archive' ) );
+		$this->assertSame( 'data:image/svg+xml;base64,legacy', $this->sut->get_icon( 'woocommerce/logo', 'data:image/svg+xml;base64,legacy' ) );
+	}
+
+	/**
+	 * @testdox Should use registered icons on WordPress 7.2, including prereleases.
+	 * @testWith ["7.2-beta4-65250"]
+	 *           ["7.2-RC1"]
+	 *           ["7.2"]
+	 *           ["7.3-alpha"]
+	 *
+	 * @param string $version WordPress version.
+	 */
+	public function test_registered_icons( string $version ): void {
+		$this->require_icon_api();
+		$GLOBALS['wp_version'] = $version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate WordPress versions.
+		$this->sut->handle_admin_menu_registration();
+
+		$this->assertSame( 'core-admin/tag', $this->sut->get_icon( 'core-admin/tag', 'dashicons-archive' ) );
+		$this->assertSame( 'core-admin/payment', $this->sut->get_icon( 'core-admin/payment', 'legacy' ) );
+		$this->assertSame( 'woocommerce/logo', $this->sut->get_icon( 'woocommerce/logo', 'legacy' ) );
+		$this->assertStringContainsString( '<svg', wp_get_icon( 'woocommerce/logo' ) );
+		$this->assertStringContainsString( 'stroke="currentColor"', wp_get_icon( 'woocommerce/megaphone' ) );
+		$this->assertStringContainsString( 'fill: none', wp_get_icon( 'woocommerce/megaphone' ) );
+	}
+
+	/**
+	 * @testdox Should fall back when an icon is not registered.
+	 */
+	public function test_missing_icon_falls_back(): void {
+		$this->require_icon_api();
+		$GLOBALS['wp_version'] = '7.2'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Exercise the missing-icon fallback on a supported version.
+		$this->assertSame( 'dashicons-archive', $this->sut->get_icon( 'woocommerce/missing', 'dashicons-archive' ) );
+	}
+
+	/**
+	 * @testdox Should update only default Products and Orders menu icons without changing post type metadata.
+	 */
+	public function test_post_type_menu_icons(): void {
+		$this->require_icon_api();
+		$GLOBALS['wp_version'] = '7.2'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Exercise the supported menu path independently of the host version.
+		$this->sut->handle_admin_menu_registration();
+		$GLOBALS['menu'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Set up menu fixtures.
+			55 => array( 'Products', 'edit_products', 'edit.php?post_type=product', 'Products', '', 'menu-posts-product', 'dashicons-archive' ),
+			56 => array( 'Orders', 'edit_shop_orders', 'edit.php?post_type=shop_order', 'Orders', '', 'menu-posts-shop_order', 'dashicons-admin-post' ),
+			57 => array( 'Posts', 'edit_posts', 'edit.php', 'Posts', '', 'menu-posts', 'dashicons-admin-post' ),
+		);
+
+		$this->sut->handle_admin_menu();
+
+		$this->assertSame( 'core-admin/tag', $GLOBALS['menu'][55][6] );
+		$this->assertSame( 'core-admin/receipt', $GLOBALS['menu'][56][6] );
+		$this->assertSame( 'dashicons-admin-post', $GLOBALS['menu'][57][6] );
+		$this->assertSame( 'edit_products', $GLOBALS['menu'][55][1] );
+		$this->assertSame( 'edit.php?post_type=product', $GLOBALS['menu'][55][2] );
+		$this->assertSame( 'dashicons-archive', get_post_type_object( 'product' )->menu_icon );
+	}
+
+	/**
+	 * @testdox Should preserve extension-provided icons and leave legacy WordPress menus unchanged.
+	 * @testWith ["7.1.3", "dashicons-archive"]
+	 *           ["7.2", "https://example.com/custom.svg"]
+	 *
+	 * @param string $version WordPress version.
+	 * @param string $icon    Existing menu icon.
+	 */
+	public function test_preserves_existing_icons( string $version, string $icon ): void {
+		$GLOBALS['wp_version'] = $version; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate WordPress versions.
+		$GLOBALS['menu']       = array( array( 'Products', 'edit_products', 'edit.php?post_type=product', 'Products', '', 'menu-posts-product', $icon ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Set up menu fixtures.
+
+		$this->sut->handle_admin_menu();
+
+		$this->assertSame( $icon, $GLOBALS['menu'][0][6] );
+	}
+
+	/**
+	 * Skip registry tests on older WordPress versions.
+	 */
+	private function require_icon_api(): void {
+		if ( ! function_exists( 'wp_is_valid_icon_name' ) ) {
+			$this->markTestSkipped( 'Requires the WordPress 7.2 icon API.' );
+		}
+	}
+}