Commit 781ce294c04 for woocommerce

commit 781ce294c04854ab51dbb04236d3114257c02ab4
Author: louwie17 <lourensschep@gmail.com>
Date:   Mon Oct 5 10:06:35 2026 +0200

    Hide the legacy Reports menu on new stores by default (#69247)

    * Hide the legacy Reports menu on new stores by default

    * Add changelog entry for hiding the legacy Reports menu

    * Update page-loads e2e test for the hidden legacy Reports menu

    * Split the legacy Reports e2e test and skip the hidden-menu check on external sites

    Target the menu link by href and assert it exists before checking it is
    hidden, since role locators skip hidden elements and matched nothing.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01GQJnWjuNUUkR7h2J4H5gHz

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu
new file mode 100644
index 00000000000..7f44297c97c
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Hide the legacy Reports menu on new stores; it still shows when Analytics is off or an extension adds legacy reports.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index 9dd03b80fe9..1a47d24707e 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -6,6 +6,7 @@
  * @version 2.5.0
  */

+use Automattic\WooCommerce\Internal\Admin\LegacyReportsMenu;
 use Automattic\WooCommerce\Internal\Admin\Marketplace;
 use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
 use Automattic\WooCommerce\Internal\Admin\Orders\PageController as Custom_Orders_PageController;
@@ -112,6 +113,8 @@ class WC_Admin_Menus {
 		} else {
 			add_menu_page( __( 'Sales reports', 'woocommerce' ), __( 'Sales reports', 'woocommerce' ), 'view_woocommerce_reports', 'wc-reports', array( $this, 'reports_page' ), 'dashicons-chart-bar', '55.6' );
 		}
+
+		add_action( 'admin_head', array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' ), PHP_INT_MAX );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
new file mode 100644
index 00000000000..024ef197517
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
@@ -0,0 +1,183 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Admin;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Admin_Menus;
+use WC_Install;
+
+/**
+ * Decides whether the legacy WooCommerce > Reports menu item is shown, and hides it when it isn't.
+ *
+ * The page itself stays registered, so admin.php?page=wc-reports keeps working either way.
+ *
+ * @internal
+ *
+ * @since 11.3.0
+ */
+final class LegacyReportsMenu {
+
+	/**
+	 * Stores first installed on this version or later hide the menu by default.
+	 */
+	private const NEW_STORE_VERSION = '11.3.0-dev';
+
+	/**
+	 * The report groups and reports that WC_Admin_Reports::get_reports() returns without extensions.
+	 */
+	private const CORE_REPORTS = array(
+		'orders'    => array( 'sales_by_date', 'sales_by_product', 'sales_by_category', 'coupon_usage', 'downloads' ),
+		'customers' => array( 'customers', 'customer_list' ),
+		'stock'     => array( 'low_in_stock', 'out_of_stock', 'most_stocked' ),
+		'taxes'     => array( 'taxes_by_code', 'taxes_by_date' ),
+	);
+
+	/**
+	 * The callback every core legacy report uses.
+	 */
+	private const CORE_CALLBACK = array( 'WC_Admin_Reports', 'get_report' );
+
+	/**
+	 * Result of should_show(), cached for the request.
+	 *
+	 * @var bool|null
+	 */
+	private ?bool $should_show = null;
+
+	/**
+	 * Hide the Reports menu item right before the admin menu is printed.
+	 *
+	 * Runs this late so that the initial installed version has been recorded and extensions
+	 * have had every chance to register their report filters.
+	 *
+	 * @internal
+	 */
+	public function handle_admin_head(): void {
+		if ( ! current_user_can( 'view_woocommerce_reports' ) || $this->should_show() ) {
+			return;
+		}
+
+		global $menu, $submenu;
+
+		if ( ! empty( $submenu['woocommerce'] ) && is_array( $submenu['woocommerce'] ) ) {
+			$first_index = array_key_first( $submenu['woocommerce'] );
+			foreach ( $submenu['woocommerce'] as $index => $item ) {
+				// WordPress links the WooCommerce parent item to its first submenu entry, hidden or not.
+				if ( 'wc-reports' === ( $item[2] ?? null ) && $index !== $first_index ) {
+					$submenu['woocommerce'][ $index ][4] = self::add_hide_class( $item[4] ?? '' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				}
+			}
+		}
+
+		if ( ! empty( $menu ) && is_array( $menu ) ) {
+			foreach ( $menu as $index => $item ) {
+				if ( 'wc-reports' === ( $item[2] ?? null ) ) {
+					$menu[ $index ][4] = self::add_hide_class( $item[4] ?? '' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+				}
+			}
+		}
+	}
+
+	/**
+	 * Whether the legacy Reports menu item should be shown.
+	 *
+	 * @return bool
+	 */
+	private function should_show(): bool {
+		if ( null !== $this->should_show ) {
+			return $this->should_show;
+		}
+
+		$show = ! $this->is_new_store()
+			|| ! FeaturesUtil::feature_is_enabled( 'analytics' )
+			|| $this->has_extension_reports();
+
+		/**
+		 * Filters whether to show the legacy WooCommerce > Reports menu item.
+		 *
+		 * The page stays reachable at admin.php?page=wc-reports either way. By default the item is hidden on
+		 * stores first installed on WooCommerce 11.3.0 or later, unless Analytics is disabled or an extension
+		 * adds legacy reports.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param bool $show Whether to show the menu item.
+		 */
+		$filtered = apply_filters( 'woocommerce_show_legacy_reports_menu', $show );
+
+		$filtered          = is_scalar( $filtered ) ? filter_var( $filtered, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : null;
+		$this->should_show = $filtered ?? $show;
+
+		return $this->should_show;
+	}
+
+	/**
+	 * Whether the store was first installed on the version that hides the menu, or later.
+	 *
+	 * A missing or malformed initial version (e.g. stores installed before 9.2.0) counts as an existing store.
+	 *
+	 * @return bool
+	 */
+	private function is_new_store(): bool {
+		$initial_version = get_option( WC_Install::INITIAL_INSTALLED_VERSION );
+
+		if ( ! is_string( $initial_version ) || ! preg_match( '/^\d+\.\d+\.\d+(-(dev|alpha|beta|rc)(\.?\d+)?)?$/i', $initial_version ) ) {
+			return false;
+		}
+
+		return version_compare( $initial_version, self::NEW_STORE_VERSION, '>=' );
+	}
+
+	/**
+	 * Whether an extension adds to or changes the legacy reports.
+	 *
+	 * Analytics also applies woocommerce_admin_reports, so hooking it isn't enough on its own: the filtered
+	 * legacy reports must contain a report core doesn't define, or a core report with a different callback.
+	 *
+	 * @return bool
+	 */
+	private function has_extension_reports(): bool {
+		if ( has_action( 'wc_reports_tabs' ) || has_filter( 'wc_admin_reports_path' ) ) {
+			return true;
+		}
+
+		if ( ! has_filter( 'woocommerce_admin_reports' ) && ! has_filter( 'woocommerce_reports_charts' ) ) {
+			return false;
+		}
+
+		if ( ! class_exists( 'WC_Admin_Reports', false ) ) {
+			return false;
+		}
+
+		foreach ( \WC_Admin_Reports::get_reports() as $group_key => $group ) {
+			if ( ! is_array( $group ) || ! is_array( $group['reports'] ?? null ) ) {
+				continue;
+			}
+
+			foreach ( $group['reports'] as $report_key => $report ) {
+				if ( ! in_array( $report_key, self::CORE_REPORTS[ $group_key ] ?? array(), true ) ) {
+					return true;
+				}
+
+				if ( ! is_array( $report ) || self::CORE_CALLBACK !== ( $report['callback'] ?? null ) ) {
+					return true;
+				}
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Append the class WordPress uses to hide admin elements when JavaScript is enabled.
+	 *
+	 * @param mixed $classes Existing menu item classes.
+	 * @return string
+	 */
+	private static function add_hide_class( $classes ): string {
+		$classes = is_string( $classes ) ? trim( $classes ) : '';
+
+		return '' === $classes ? WC_Admin_Menus::HIDE_CSS_CLASS : $classes . ' ' . WC_Admin_Menus::HIDE_CSS_CLASS;
+	}
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts b/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
index e9243efd8f9..e9806b5ae8a 100644
--- a/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/basic/page-loads.spec.ts
@@ -9,7 +9,7 @@ import {
 /**
  * Internal dependencies
  */
-import { test, expect } from '../../fixtures/fixtures';
+import { test, expect, tags } from '../../fixtures/fixtures';
 import { getFakeProduct } from '../../utils/data';
 import { ADMIN_STATE_PATH } from '../../playwright.config';

@@ -38,12 +38,6 @@ const wcPages = [
 				element: '.woocommerce-dropdown-button__labels',
 				text: 'All Customers',
 			},
-			{
-				name: 'Reports',
-				heading: 'Reports',
-				element: '.nav-tab-wrapper > .nav-tab-active',
-				text: 'Orders',
-			},
 			{
 				name: 'Settings',
 				heading: 'Settings',
@@ -290,3 +284,30 @@ for ( const currentPage of wcPages ) {
 		}
 	} );
 }
+
+// External sites were installed before the menu was hidden, so Reports stays visible there.
+test(
+	'hides the legacy Reports menu item on a new store',
+	{ tag: [ tags.SKIP_ON_EXTERNAL_ENV ] },
+	async ( { page } ) => {
+		await page.goto( 'wp-admin/admin.php?page=wc-settings' );
+
+		// Target by href: role locators skip hidden elements, so they would match nothing here.
+		const reportsLink = page.locator(
+			'li.wp-menu-open > ul.wp-submenu a[href="admin.php?page=wc-reports"]'
+		);
+		await expect( reportsLink ).toHaveCount( 1 );
+		await expect( reportsLink ).toBeHidden();
+	}
+);
+
+test( 'can load the legacy Reports page directly', async ( { page } ) => {
+	await page.goto( 'wp-admin/admin.php?page=wc-reports' );
+
+	await expect(
+		page.getByRole( 'heading', { name: 'Reports' } ).first()
+	).toBeVisible();
+	await expect(
+		page.locator( '.nav-tab-wrapper > .nav-tab-active' )
+	).toContainText( 'Orders' );
+} );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
new file mode 100644
index 00000000000..15a0e164408
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
@@ -0,0 +1,513 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\LegacyReportsMenu;
+use WC_Admin_Menus;
+use WC_Admin_Reports;
+use WC_Install;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the LegacyReportsMenu class.
+ */
+class LegacyReportsMenuTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Admin menu globals that the tests mutate.
+	 */
+	private const MENU_GLOBALS = array( 'menu', 'submenu', 'admin_page_hooks', '_registered_pages', '_parent_pages', '_wp_menu_nopriv', '_wp_submenu_nopriv', 'pagenow', 'plugin_page', 'parent_file' );
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var LegacyReportsMenu
+	 */
+	private $sut;
+
+	/**
+	 * Backup of the admin menu globals.
+	 *
+	 * @var array
+	 */
+	private $globals_backup = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( self::MENU_GLOBALS as $name ) {
+			$this->globals_backup[ $name ] = $GLOBALS[ $name ] ?? null;
+		}
+
+		$GLOBALS['menu']    = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$GLOBALS['submenu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		update_option( 'woocommerce_analytics_enabled', 'yes' );
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.3.0' );
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$this->sut = new LegacyReportsMenu();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		try {
+			foreach ( $this->globals_backup as $name => $value ) {
+				$GLOBALS[ $name ] = $value; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			}
+			// The container's LegacyReportsMenu caches its decision for the request.
+			$this->reset_container_resolutions();
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should treat the store as new only when the initial installed version is a valid 11.3.0 pre-release or later.
+	 *
+	 * @testWith ["11.3.0", false]
+	 *           ["11.3.0-dev", false]
+	 *           ["11.3.0-beta.1", false]
+	 *           ["11.3.0-RC1", false]
+	 *           ["11.4.0", false]
+	 *           ["12.0.0", false]
+	 *           ["11.2.9", true]
+	 *           ["11.2.0-rc.1", true]
+	 *           ["9.2.0", true]
+	 *           ["", true]
+	 *           ["abc", true]
+	 *           ["99.0garbage", true]
+	 *           ["12.0", true]
+	 *           ["12.0.0-foo", true]
+	 *
+	 * @param string $initial_version The stored initial installed version.
+	 * @param bool   $expected_show   Whether the menu is expected to be shown.
+	 */
+	public function test_new_store_detection( string $initial_version, bool $expected_show ): void {
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+
+		$this->assertSame( $expected_show, $this->is_menu_shown(), "Unexpected result for initial version '{$initial_version}'" );
+	}
+
+	/**
+	 * @testdox Should treat a missing or non-string initial installed version as an existing store.
+	 */
+	public function test_missing_or_invalid_version_type_shows_menu(): void {
+		delete_option( WC_Install::INITIAL_INSTALLED_VERSION );
+		$this->assertTrue( $this->is_menu_shown(), 'Missing option should count as an existing store' );
+
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, array( '11.3.0' ) );
+		$this->assertTrue( $this->is_menu_shown(), 'Array value should count as an existing store' );
+	}
+
+	/**
+	 * @testdox Should hide the menu on a new store without extensions.
+	 */
+	public function test_hidden_on_new_store_without_extensions(): void {
+		$this->assertFalse( has_filter( 'woocommerce_admin_reports' ), 'Precondition: core does not hook the legacy reports filter' );
+
+		$this->assertFalse( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu on a new store when Analytics is disabled.
+	 */
+	public function test_shown_on_new_store_when_analytics_disabled(): void {
+		update_option( 'woocommerce_analytics_enabled', 'no' );
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when an extension adds a legacy report group.
+	 */
+	public function test_shown_when_legacy_group_added(): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) {
+				$reports['subscriptions'] = array(
+					'title'   => 'Subscriptions',
+					'reports' => array(
+						'subscription_events_by_date' => array(
+							'title'    => 'Subscription events',
+							'callback' => '__return_empty_string',
+						),
+					),
+				);
+				return $reports;
+			}
+		);
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when an extension adds a report to a core group.
+	 */
+	public function test_shown_when_report_added_to_core_group(): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) {
+				$reports['stock']['reports']['insufficient_stock'] = array(
+					'title'    => 'Insufficient stock',
+					'callback' => '__return_empty_string',
+				);
+				return $reports;
+			}
+		);
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when an extension adds reports with the legacy charts and function keys.
+	 */
+	public function test_shown_when_legacy_charts_keys_used(): void {
+		add_filter(
+			'woocommerce_reports_charts',
+			function ( $reports ) {
+				$reports['legacy'] = array(
+					'title'  => 'Legacy',
+					'charts' => array(
+						'chart' => array(
+							'title'    => 'Chart',
+							'function' => '__return_empty_string',
+						),
+					),
+				);
+				return $reports;
+			}
+		);
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when an extension replaces the callback of a core report.
+	 *
+	 * @testWith ["callback"]
+	 *           ["function"]
+	 *
+	 * @param string $key The report key used to replace the callback.
+	 */
+	public function test_shown_when_core_report_callback_replaced( string $key ): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) use ( $key ) {
+				$reports['orders']['reports']['sales_by_date'][ $key ] = '__return_empty_string';
+				return $reports;
+			}
+		);
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should keep the menu hidden when an extension only adds an Analytics report.
+	 */
+	public function test_hidden_when_only_analytics_report_added(): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) {
+				$reports[] = array(
+					'slug'        => 'my-extension/stats',
+					'description' => 'Stats from my extension.',
+					'path'        => '/my-extension/v1/reports/stats',
+				);
+				return $reports;
+			}
+		);
+
+		$this->assertFalse( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when a callback adds both an Analytics report and a legacy report group.
+	 */
+	public function test_shown_when_callback_returns_both_shapes(): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) {
+				$reports[]           = array(
+					'slug'        => 'my-extension/stats',
+					'description' => 'Stats from my extension.',
+				);
+				$reports['my_group'] = array(
+					'title'   => 'My group',
+					'reports' => array(
+						'my_report' => array(
+							'title'    => 'My report',
+							'callback' => '__return_empty_string',
+						),
+					),
+				);
+				return $reports;
+			}
+		);
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should keep the menu hidden when an extension only removes reports.
+	 */
+	public function test_hidden_when_reports_only_removed(): void {
+		add_filter(
+			'woocommerce_admin_reports',
+			function ( $reports ) {
+				unset( $reports['customers'], $reports['orders']['reports']['downloads'] );
+				return $reports;
+			}
+		);
+
+		$this->assertFalse( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should show the menu when an extension hooks the legacy report tabs or report path.
+	 *
+	 * @testWith ["wc_reports_tabs"]
+	 *           ["wc_admin_reports_path"]
+	 *
+	 * @param string $hook The hook an extension uses.
+	 */
+	public function test_shown_when_legacy_report_hooks_used( string $hook ): void {
+		add_filter( $hook, '__return_null' );
+
+		$this->assertTrue( $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should let the override filter show or hide the menu, coercing its return value.
+	 *
+	 * @testWith [true, "11.3.0", true]
+	 *           [false, "11.2.0", false]
+	 *           ["yes", "11.3.0", true]
+	 *           ["no", "11.2.0", false]
+	 *           ["off", "11.2.0", false]
+	 *           [1, "11.3.0", true]
+	 *           [0, "11.2.0", false]
+	 *           ["1", "11.3.0", true]
+	 *           ["", "11.2.0", false]
+	 *
+	 * @param mixed  $filtered        The value returned by the filter.
+	 * @param string $initial_version The stored initial installed version.
+	 * @param bool   $expected        Whether the menu is expected to be shown.
+	 */
+	public function test_override_filter( $filtered, string $initial_version, bool $expected ): void {
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+		add_filter(
+			'woocommerce_show_legacy_reports_menu',
+			function () use ( $filtered ) {
+				return $filtered;
+			}
+		);
+
+		$this->assertSame( $expected, $this->is_menu_shown() );
+	}
+
+	/**
+	 * @testdox Should fall back to the default when the override filter returns an invalid value.
+	 *
+	 * @testWith ["11.3.0", false]
+	 *           ["11.2.0", true]
+	 *
+	 * @param string $initial_version The stored initial installed version.
+	 * @param bool   $expected        Whether the menu is expected to be shown.
+	 */
+	public function test_override_filter_invalid_values_fall_back_to_default( string $initial_version, bool $expected ): void {
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, $initial_version );
+
+		foreach ( array( null, array( true ), new \stdClass(), 'maybe', 2 ) as $invalid ) {
+			$callback = function () use ( $invalid ) {
+				return $invalid;
+			};
+			add_filter( 'woocommerce_show_legacy_reports_menu', $callback );
+
+			$this->assertSame( $expected, $this->is_menu_shown(), 'Invalid value: ' . wp_json_encode( $invalid ) );
+
+			remove_filter( 'woocommerce_show_legacy_reports_menu', $callback );
+		}
+	}
+
+	/**
+	 * @testdox Should keep the core report list in sync with the unfiltered legacy reports.
+	 */
+	public function test_core_reports_list_matches_legacy_reports(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+
+		$expected = array();
+		foreach ( WC_Admin_Reports::get_reports() as $group_key => $group ) {
+			$expected[ $group_key ] = array_keys( $group['reports'] );
+		}
+
+		$core_reports = ( new \ReflectionClassConstant( LegacyReportsMenu::class, 'CORE_REPORTS' ) )->getValue();
+
+		$this->assertSame( $expected, $core_reports, 'LegacyReportsMenu::CORE_REPORTS must list every core legacy report' );
+	}
+
+	/**
+	 * @testdox Should register the container instance on admin_head from reports_menu() so production hides the item.
+	 */
+	public function test_reports_menu_registers_admin_head_handler(): void {
+		$this->reset_container_resolutions();
+		$this->register_woocommerce_menu();
+
+		$handler = array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' );
+		$this->assertSame( PHP_INT_MAX, has_action( 'admin_head', $handler ), 'reports_menu() must hook the handler late on admin_head' );
+
+		call_user_func( $handler );
+
+		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+	}
+
+	/**
+	 * @testdox Should hide the WooCommerce > Reports item on a new store but keep the page accessible.
+	 */
+	public function test_admin_head_hides_submenu_item_and_keeps_page_accessible(): void {
+		$this->register_woocommerce_menu();
+
+		$this->sut->handle_admin_head();
+
+		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-settings' ), 'Other items must stay visible' );
+		$this->assertTrue( $this->can_access_reports_page(), 'The reports page must stay accessible by URL' );
+	}
+
+	/**
+	 * @testdox Should not hide the WooCommerce > Reports item on an existing store.
+	 */
+	public function test_admin_head_keeps_submenu_item_on_existing_store(): void {
+		update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.2.0' );
+		$this->register_woocommerce_menu();
+
+		$this->sut->handle_admin_head();
+
+		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+	}
+
+	/**
+	 * @testdox Should not hide Reports when it is the first WooCommerce submenu item, since WordPress links the parent to it.
+	 */
+	public function test_admin_head_keeps_reports_when_first_submenu_item(): void {
+		$GLOBALS['submenu']['woocommerce'] = array( array( 'Reports', 'view_woocommerce_reports', 'wc-reports', 'Reports' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		$this->sut->handle_admin_head();
+
+		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+	}
+
+	/**
+	 * @testdox Should not recreate a Reports item that another plugin removed.
+	 */
+	public function test_admin_head_does_not_recreate_removed_item(): void {
+		$this->register_woocommerce_menu();
+		remove_submenu_page( 'woocommerce', 'wc-reports' );
+		$submenu_before = $GLOBALS['submenu'];
+
+		$this->sut->handle_admin_head();
+
+		$this->assertSame( $submenu_before, $GLOBALS['submenu'] );
+	}
+
+	/**
+	 * @testdox Should hide the top-level Sales reports item for users who can view reports but not the WooCommerce menu.
+	 */
+	public function test_admin_head_hides_top_level_item_for_reports_only_user(): void {
+		wp_set_current_user( $this->create_reports_only_user() );
+		$this->assertFalse( WC_Admin_Menus::can_view_woocommerce_menu_item(), 'Precondition: user cannot see the WooCommerce menu' );
+
+		( new WC_Admin_Menus() )->reports_menu();
+		$this->sut->handle_admin_head();
+
+		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'menu', 'wc-reports' ) );
+		$this->assertTrue( $this->can_access_reports_page(), 'The reports page must stay accessible by URL' );
+	}
+
+	/**
+	 * @testdox Should keep denying the reports page to users without the reports capability.
+	 */
+	public function test_reports_page_denied_without_capability(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+		( new WC_Admin_Menus() )->reports_menu();
+		$this->sut->handle_admin_head();
+
+		$this->assertFalse( $this->can_access_reports_page() );
+	}
+
+	/**
+	 * Build the WooCommerce menu, run the admin_head handler on a fresh instance, and report whether Reports is visible.
+	 *
+	 * @return bool
+	 */
+	private function is_menu_shown(): bool {
+		$GLOBALS['menu']    = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$GLOBALS['submenu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$this->register_woocommerce_menu();
+
+		( new LegacyReportsMenu() )->handle_admin_head();
+
+		return false === strpos( $this->get_menu_item_classes( 'submenu', 'wc-reports' ), WC_Admin_Menus::HIDE_CSS_CLASS );
+	}
+
+	/**
+	 * Register the WooCommerce menu with Reports and Settings items, as admin_menu does.
+	 */
+	private function register_woocommerce_menu(): void {
+		$menus = new WC_Admin_Menus();
+		$menus->admin_menu();
+		$menus->reports_menu();
+		$menus->settings_menu();
+	}
+
+	/**
+	 * Create a user that can view reports but cannot see the WooCommerce menu.
+	 *
+	 * @return int
+	 */
+	private function create_reports_only_user(): int {
+		$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+		( new \WP_User( $user_id ) )->add_cap( 'view_woocommerce_reports' );
+
+		return $user_id;
+	}
+
+	/**
+	 * Get the CSS classes of the wc-* item in $menu or $submenu['woocommerce'].
+	 *
+	 * @param string $menu_global The global to search: 'menu' or 'submenu'.
+	 * @param string $slug        The menu slug.
+	 * @return string
+	 */
+	private function get_menu_item_classes( string $menu_global, string $slug ): string {
+		$items = 'menu' === $menu_global ? $GLOBALS['menu'] : ( $GLOBALS['submenu']['woocommerce'] ?? array() );
+
+		foreach ( $items as $item ) {
+			if ( $slug === $item[2] ) {
+				return (string) ( $item[4] ?? '' );
+			}
+		}
+
+		$this->fail( "Menu item {$slug} not found in \${$menu_global}" );
+	}
+
+	/**
+	 * Whether the current user can open admin.php?page=wc-reports.
+	 *
+	 * @return bool
+	 */
+	private function can_access_reports_page(): bool {
+		$GLOBALS['pagenow']     = 'admin.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$GLOBALS['plugin_page'] = 'wc-reports'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$GLOBALS['parent_file'] = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		return user_can_access_admin_page();
+	}
+}