Commit 0b200f19a1b for woocommerce

commit 0b200f19a1bfb244e932dded49e427837c779644
Author: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
Date:   Thu Oct 29 12:48:22 2026 -0700

    Report the loaded Action Scheduler copy in the System Status Report and REST API (#69882)

    * Report the loaded Action Scheduler copy in the System Status Report and REST API

    The status report row printed the newest registered Action Scheduler version next to the path of the copy actually loaded, so it misreported sites where an older copy wins the load race. The v2 and v3 system_status responses gain an environment.action_scheduler object (loaded version, newest registered version, source and a risky flag) read from ActionSchedulerUtil, and the row renders from it.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Simplify the simulated Action Scheduler copy in the system status tests

    The fake copy now lives in a temp directory mapped to a plugin directory through wp_plugin_paths, as the util tests do, and relies on its version header alone to be risky. The legacy tests compare the reported path to the loaded plugin path instead of to the util.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Remove the resolved PHPStan baseline entry for the status report view

    The Action Scheduler row no longer defines $path conditionally, so the "Variable $path might not be defined" entry is stale and the CI PHPStan job reports it as unmatched.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Fix the pre-existing ESLint findings in the system status e2e test

    The branch lint checks a changed file whole, so the file's unused eslint-disable directives, two Prettier indentation errors and a missing blank line now fail the Lint job.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Rename the loaded Action Scheduler version key to loaded_version

    * Drop the redundant source name check before the switch

    * Quote the plugin or theme name in the Action Scheduler source sentence

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/update-wooplug-2435-system-status-action-scheduler b/plugins/woocommerce/changelog/update-wooplug-2435-system-status-action-scheduler
new file mode 100644
index 00000000000..9e19dbde2f1
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-wooplug-2435-system-status-action-scheduler
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Show which Action Scheduler copy is running, where it is loaded from, and a warning when it lacks features WooCommerce needs, in the System Status Report and REST API.
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
index 1f8ac75f1d7..b7f58ceb720 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-page-status-report.php
@@ -153,20 +153,50 @@ if ( file_exists( $plugin_path ) ) {
 		</tr>
 		<tr>
 			<td data-export-label="Action Scheduler Version"><?php esc_html_e( 'Action Scheduler package', 'woocommerce' ); ?>:</td>
-			<td class="help"><?php echo wc_help_tip( esc_html__( 'Action Scheduler package running on your site.', 'woocommerce' ) ); ?></td>
+			<td class="help"><?php echo wc_help_tip( esc_html__( 'The Action Scheduler package running on your site and where it is loaded from. Other plugins may bundle a different version.', 'woocommerce' ) ); ?></td>
 			<td>
 				<?php
-				if ( class_exists( 'ActionScheduler_Versions' ) && class_exists( 'ActionScheduler' ) ) {
-					$version = ActionScheduler_Versions::instance()->latest_version();
-					$path    = ActionScheduler::plugin_path( '' ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-				} else {
-					$version = null;
-				}
+				$action_scheduler = $environment['action_scheduler'] ?? array();
+				$as_version       = $action_scheduler['loaded_version'] ?? null;

-				if ( ! is_null( $version ) ) {
-					echo '<mark class="yes"><span class="dashicons dashicons-yes"></span> ' . esc_html( $version ) . ' <code class="private">' . esc_html( $path ) . '</code></mark> ';
-				} else {
+				if ( is_null( $as_version ) ) {
 					echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Unable to detect the Action Scheduler package.', 'woocommerce' ) . '</mark>';
+				} else {
+					if ( ! empty( $action_scheduler['is_risky'] ) ) {
+						/* Translators: %s: Action Scheduler version. */
+						echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . sprintf( esc_html__( '%s - This version of Action Scheduler lacks features WooCommerce relies on. Update the plugin or theme it is loaded from.', 'woocommerce' ), esc_html( $as_version ) ) . '</mark>';
+					} else {
+						echo '<mark class="yes"><span class="dashicons dashicons-yes"></span> ' . esc_html( $as_version ) . '</mark>';
+					}
+
+					$as_source_name = (string) ( $action_scheduler['source_name'] ?? '' );
+					$as_source_type = (string) ( $action_scheduler['source_type'] ?? '' );
+					switch ( $as_source_type ) {
+						case 'woocommerce':
+						case 'plugin':
+							/* Translators: %s: Plugin name. */
+							$as_source_text = sprintf( __( 'Loaded from the "%s" plugin.', 'woocommerce' ), $as_source_name );
+							break;
+						case 'mu-plugin':
+							/* Translators: %s: Must-use plugin name. */
+							$as_source_text = sprintf( __( 'Loaded from the "%s" must-use plugin.', 'woocommerce' ), $as_source_name );
+							break;
+						case 'theme':
+							/* Translators: %s: Theme name. */
+							$as_source_text = sprintf( __( 'Loaded from the "%s" theme.', 'woocommerce' ), $as_source_name );
+							break;
+						default:
+							$as_source_text = __( 'Loaded from an unknown location.', 'woocommerce' );
+					}
+					echo ' ' . esc_html( $as_source_text );
+
+					$as_registered_version = $action_scheduler['registered_version'] ?? null;
+					if ( is_string( $as_registered_version ) && version_compare( $as_registered_version, $as_version, '>' ) ) {
+						/* Translators: %s: Action Scheduler version. */
+						echo ' ' . sprintf( esc_html__( '%s is also installed but not in use.', 'woocommerce' ), esc_html( $as_registered_version ) );
+					}
+
+					echo ' <code class="private">' . esc_html( (string) ( $action_scheduler['source_path'] ?? '' ) ) . '</code>';
 				}
 				?>
 			</td>
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
index c3fe2ceca74..d4b3b68d307 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller.php
@@ -17,6 +17,7 @@ use Automattic\WooCommerce\Utilities\{ LoggingUtil, OrderUtil, PluginUtil };
 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Enums\{ DefaultCustomerAddress, TaxBasedOn, TaxDisplayMode };
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use Automattic\WooCommerce\Proxies\LegacyProxy;

 /**
@@ -167,6 +168,51 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller {
 							'context'     => array( 'view' ),
 							'readonly'    => true,
 						),
+						'action_scheduler'          => array(
+							'description' => __( 'The Action Scheduler copy that is loaded. It can be older than the newest copy installed.', 'woocommerce' ),
+							'type'        => 'object',
+							'context'     => array( 'view' ),
+							'readonly'    => true,
+							'properties'  => array(
+								'loaded_version'     => array(
+									'description' => __( 'Version of the loaded copy, or null when none is loaded.', 'woocommerce' ),
+									'type'        => array( 'string', 'null' ),
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+								'registered_version' => array(
+									'description' => __( 'Newest version installed, which may differ from the loaded one.', 'woocommerce' ),
+									'type'        => array( 'string', 'null' ),
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+								'source_type'        => array(
+									'description' => __( 'What loads the copy: woocommerce, plugin, mu-plugin, theme or other.', 'woocommerce' ),
+									'type'        => array( 'string', 'null' ),
+									'enum'        => array( 'woocommerce', 'plugin', 'mu-plugin', 'theme', 'other', null ),
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+								'source_name'        => array(
+									'description' => __( 'Name of the plugin or theme that loads the copy. Empty when unknown.', 'woocommerce' ),
+									'type'        => array( 'string', 'null' ),
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+								'source_path'        => array(
+									'description' => __( 'Directory of the loaded copy.', 'woocommerce' ),
+									'type'        => array( 'string', 'null' ),
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+								'is_risky'           => array(
+									'description' => __( 'Does the loaded copy lack features WooCommerce relies on?', 'woocommerce' ),
+									'type'        => 'boolean',
+									'context'     => array( 'view' ),
+									'readonly'    => true,
+								),
+							),
+						),
 						'log_directory'             => array(
 							'description' => __( 'Log directory.', 'woocommerce' ),
 							'type'        => 'string',
@@ -1092,12 +1138,25 @@ class WC_REST_System_Status_V2_Controller extends WC_REST_Controller {
 		$database_version = wc_get_server_database_version();
 		$log_directory    = LoggingUtil::get_log_directory( false );

+		// The copy answering calls can be older than the newest registered one, so report both.
+		$action_scheduler_path   = ActionSchedulerUtil::get_loaded_path();
+		$action_scheduler_source = null === $action_scheduler_path ? null : ActionSchedulerUtil::get_loaded_source();
+		$action_scheduler        = array(
+			'loaded_version'     => ActionSchedulerUtil::get_loaded_version(),
+			'registered_version' => ActionSchedulerUtil::get_highest_registered_version(),
+			'source_type'        => $action_scheduler_source['type'] ?? null,
+			'source_name'        => $action_scheduler_source['name'] ?? null,
+			'source_path'        => $action_scheduler_path,
+			'is_risky'           => ActionSchedulerUtil::is_risky(),
+		);
+
 		// Return all environment info. Described by JSON Schema.
 		return array(
 			'home_url'                  => get_option( 'home' ),
 			'site_url'                  => get_option( 'siteurl' ),
 			'store_id'                  => get_option( \WC_Install::STORE_ID_OPTION, null ),
 			'version'                   => WC()->version,
+			'action_scheduler'          => $action_scheduler,
 			'log_directory'             => $log_directory,
 			'log_directory_writable'    => wp_is_writable( $log_directory ),
 			'wp_version'                => get_bloginfo( 'version' ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 1c158ad6c22..fac0ae6af89 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -7920,12 +7920,6 @@ parameters:
 			count: 1
 			path: includes/admin/views/html-admin-page-status-report.php

-		-
-			message: '#^Variable \$path might not be defined\.$#'
-			identifier: variable.undefined
-			count: 1
-			path: includes/admin/views/html-admin-page-status-report.php
-
 		-
 			message: '#^Variable \$tools might not be defined\.$#'
 			identifier: variable.undefined
diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
index 385de01a7c8..c4b97a456ce 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/system-status/system-status-crud.test.ts
@@ -15,11 +15,9 @@ const expect = baseExpect.extend( {
 	anyOf( received, expectedTypes ) {
 		const assertionName = 'anyOf';
 		let pass: boolean;
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
 		let matcherResult: any;

 		try {
-			// eslint-disable-next-line playwright/no-standalone-expect -- Custom matcher definition, not a standalone assertion.
 			baseExpect( expectedTypes ).toContain( typeof received );
 			pass = true;
 		} catch ( e ) {
@@ -43,7 +41,7 @@ const expect = baseExpect.extend( {
 					( matcherResult
 						? `Received: ${ this.utils.printReceived(
 								matcherResult.actual
-						  ) }`
+							) }`
 						: '' )
 			: () =>
 					this.utils.matcherHint(
@@ -60,7 +58,7 @@ const expect = baseExpect.extend( {
 					( matcherResult
 						? `Received: ${ this.utils.printReceived(
 								matcherResult.actual
-						  ) }`
+							) }`
 						: '' );

 		return {
@@ -79,6 +77,7 @@ const schemas = {
 		{ field: 'home_url', type: any( String ) },
 		{ field: 'site_url', type: any( String ) },
 		{ field: 'version', type: any( String ) },
+		{ field: 'action_scheduler', type: anything() },
 		{ field: 'log_directory', type: any( String ) },
 		{ field: 'log_directory_writable', type: any( Boolean ) },
 		{ field: 'wp_version', type: any( String ) },
@@ -114,6 +113,14 @@ const schemas = {
 			type: anyOf( [ 'boolean', 'string', 'number' ] ),
 		},
 	],
+	environment_action_scheduler: [
+		{ field: 'loaded_version', type: any( String ) },
+		{ field: 'registered_version', type: any( String ) },
+		{ field: 'source_type', type: any( String ) },
+		{ field: 'source_name', type: any( String ) },
+		{ field: 'source_path', type: any( String ) },
+		{ field: 'is_risky', type: any( Boolean ) },
+	],
 	database: [
 		{ field: 'wc_database_version', type: any( String ) },
 		{ field: 'database_prefix', type: any( String ) },
@@ -258,7 +265,6 @@ const getExpectedWooCommerceTables = ( dbPrefix ) => {
 /* eslint-disable playwright/no-nested-step */
 test.describe( 'System Status API tests', () => {
 	test( 'can view all system status items', async ( { request } ) => {
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any
 		let responseJSON: any,
 			databasePrefix,
 			databaseSize,
@@ -303,6 +309,20 @@ test.describe( 'System Status API tests', () => {
 					`Expect "environment.external_object_cache" to be either null or a boolean.`
 				).toBeTruthy();
 			} );
+
+			await test.step( 'Verify "environment.action_scheduler" fields', () => {
+				const { action_scheduler } = environment;
+
+				for ( const {
+					field,
+					type,
+				} of schemas.environment_action_scheduler ) {
+					expect(
+						action_scheduler[ field ],
+						`Verify type of "environment.action_scheduler.${ field }"`
+					).toEqual( type );
+				}
+			} );
 		} );

 		await test.step( 'Verify "database" fields', async () => {
@@ -429,6 +449,7 @@ test.describe( 'System Status API tests', () => {

 			for ( const dropin of dropins ) {
 				const { name, plugin } = dropin;
+
 				await test.step( `Verify dropin "${ name }"`, async () => {
 					expect( name, `Verify type of "name"` ).toEqual(
 						any( String )
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
index 369093e6a4e..7e2ee3e2d43 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/system-status.php
@@ -5,6 +5,7 @@
  * @package Automattic/WooCommerce/Tests
  */

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
 use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;

 /**
@@ -116,7 +117,7 @@ class WC_Tests_REST_System_Status_V2 extends WC_REST_Unit_Test_Case {
 		$environment = (array) $this->fetch_or_get_system_status_data_for_user( self::$administrator_user )['environment'];

 		// Make sure all expected data is present.
-		$this->assertEquals( 35, count( $environment ) );
+		$this->assertEquals( 36, count( $environment ) );

 		// Test some responses to make sure they match up.
 		$this->assertEquals( get_option( 'home' ), $environment['home_url'] );
@@ -125,6 +126,15 @@ class WC_Tests_REST_System_Status_V2 extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $store_id, $environment['store_id'] );
 		$this->assertEquals( WC()->version, $environment['version'] );
 		$this->assertEquals( wp_get_environment_type(), $environment['wp_environment_type'] );
+
+		// The test environment loads the Action Scheduler copy bundled with WooCommerce.
+		$action_scheduler = (array) $environment['action_scheduler'];
+		$this->assertSame( ActionSchedulerUtil::get_bundled_version(), $action_scheduler['loaded_version'] );
+		$this->assertSame( ActionSchedulerUtil::get_highest_registered_version(), $action_scheduler['registered_version'] );
+		$this->assertSame( 'woocommerce', $action_scheduler['source_type'] );
+		$this->assertSame( 'WooCommerce', $action_scheduler['source_name'] );
+		$this->assertSame( trailingslashit( wp_normalize_path( ActionScheduler::plugin_path( '' ) ) ), $action_scheduler['source_path'] );
+		$this->assertFalse( $action_scheduler['is_risky'] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
index c276aa84ebc..932b9a8aa82 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/system-status.php
@@ -5,6 +5,8 @@
  * @package Automattic/WooCommerce/Tests
  */

+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
 /**
  * System Status REST Tests.
  *
@@ -153,7 +155,7 @@ class WC_Tests_REST_System_Status extends WC_REST_Unit_Test_Case {
 		$environment = (array) $this->fetch_or_get_system_status_data_for_user( self::$administrator_user )['environment'];

 		// Make sure all expected data is present.
-		$this->assertEquals( 35, count( $environment ) );
+		$this->assertEquals( 36, count( $environment ) );

 		// Test some responses to make sure they match up.
 		$this->assertEquals( get_option( 'home' ), $environment['home_url'] );
@@ -162,6 +164,15 @@ class WC_Tests_REST_System_Status extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( $store_id, $environment['store_id'] );
 		$this->assertEquals( WC()->version, $environment['version'] );
 		$this->assertEquals( wp_get_environment_type(), $environment['wp_environment_type'] );
+
+		// The test environment loads the Action Scheduler copy bundled with WooCommerce.
+		$action_scheduler = (array) $environment['action_scheduler'];
+		$this->assertSame( ActionSchedulerUtil::get_bundled_version(), $action_scheduler['loaded_version'] );
+		$this->assertSame( ActionSchedulerUtil::get_highest_registered_version(), $action_scheduler['registered_version'] );
+		$this->assertSame( 'woocommerce', $action_scheduler['source_type'] );
+		$this->assertSame( 'WooCommerce', $action_scheduler['source_name'] );
+		$this->assertSame( trailingslashit( wp_normalize_path( ActionScheduler::plugin_path( '' ) ) ), $action_scheduler['source_path'] );
+		$this->assertFalse( $action_scheduler['is_risky'] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller-test.php
index 013a97ae3e8..5f31d40b009 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-system-status-v2-controller-test.php
@@ -2,6 +2,7 @@
 declare( strict_types = 1 );

 use Automattic\WooCommerce\Enums\{ DefaultCustomerAddress, TaxBasedOn, TaxDisplayMode };
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;

 /**
  * Tests for WC_REST_System_Status_V2_Controller.
@@ -24,6 +25,20 @@ class WC_REST_System_Status_V2_Controller_Test extends WC_REST_Unit_Test_Case {
 	 */
 	private const TEST_PLUGIN_FILE = 'sample-woo-plugin/sample-woo-plugin.php';

+	/**
+	 * Directory name, inside WP_PLUGIN_DIR, of the fake plugin that bundles a simulated Action Scheduler copy.
+	 *
+	 * @var string
+	 */
+	private const FAKE_PLUGIN_DIR = 'wc-ssr-test-plugin';
+
+	/**
+	 * Fake Action Scheduler plugin file written by a test, or null.
+	 *
+	 * @var string|null
+	 */
+	private ?string $fake_action_scheduler_file = null;
+
 	/**
 	 * Set up test fixtures.
 	 */
@@ -31,6 +46,7 @@ class WC_REST_System_Status_V2_Controller_Test extends WC_REST_Unit_Test_Case {
 		parent::setUp();
 		$this->sut = new WC_REST_System_Status_V2_Controller();
 		delete_transient( 'wc_system_status_theme_info' );
+		ActionSchedulerUtil::reset_cache();
 	}

 	/**
@@ -40,6 +56,15 @@ class WC_REST_System_Status_V2_Controller_Test extends WC_REST_Unit_Test_Case {
 		try {
 			remove_all_filters( 'wc_get_template' );
 			delete_transient( 'wc_system_status_theme_info' );
+			ActionSchedulerUtil::reset_cache();
+			$this->reset_legacy_proxy_mocks();
+
+			if ( null !== $this->fake_action_scheduler_file ) {
+				wp_delete_file( $this->fake_action_scheduler_file );
+				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+				rmdir( dirname( $this->fake_action_scheduler_file ) );
+				$this->fake_action_scheduler_file = null;
+			}

 			if ( file_exists( WP_PLUGIN_DIR . '/' . self::TEST_PLUGIN_FILE ) ) {
 				delete_plugins( array( self::TEST_PLUGIN_FILE ) );
@@ -228,4 +253,80 @@ class WC_REST_System_Status_V2_Controller_Test extends WC_REST_Unit_Test_Case {

 		$this->assertSame( $expected, $environment['wp_memory_limit'] );
 	}
+
+	/**
+	 * @testdox Should report the bundled Action Scheduler copy as loaded from WooCommerce and not risky.
+	 */
+	public function test_get_environment_info_reports_the_bundled_action_scheduler_copy(): void {
+		$environment = $this->sut->get_environment_info_per_fields( array() );
+
+		$this->assertSame( ActionSchedulerUtil::get_bundled_version(), $environment['action_scheduler']['loaded_version'] );
+		$this->assertSame( ActionSchedulerUtil::get_highest_registered_version(), $environment['action_scheduler']['registered_version'] );
+		$this->assertSame( 'woocommerce', $environment['action_scheduler']['source_type'] );
+		$this->assertSame( 'WooCommerce', $environment['action_scheduler']['source_name'] );
+		$this->assertSame( trailingslashit( wp_normalize_path( ActionScheduler::plugin_path( '' ) ) ), $environment['action_scheduler']['source_path'] );
+		$this->assertFalse( $environment['action_scheduler']['is_risky'] );
+	}
+
+	/**
+	 * @testdox Should report the version, plugin and path of an older loaded Action Scheduler copy, and flag it as risky.
+	 */
+	public function test_get_environment_info_reports_an_older_loaded_action_scheduler_copy(): void {
+		$path = $this->simulate_loaded_action_scheduler( '3.2.0' );
+		$this->register_legacy_proxy_function_mocks(
+			array( 'get_plugins' => fn( string $folder ) => '/' . self::FAKE_PLUGIN_DIR === $folder ? array( 'some-plugin.php' => array( 'Name' => 'Some Plugin' ) ) : array() )
+		);
+
+		$environment = $this->sut->get_environment_info_per_fields( array() );
+
+		$this->assertSame( '3.2.0', $environment['action_scheduler']['loaded_version'] );
+		$this->assertSame( ActionSchedulerUtil::get_bundled_version(), $environment['action_scheduler']['registered_version'], 'The registry still reports the bundled copy as the highest version' );
+		$this->assertSame( 'plugin', $environment['action_scheduler']['source_type'] );
+		$this->assertSame( 'Some Plugin', $environment['action_scheduler']['source_name'] );
+		$this->assertSame( trailingslashit( wp_normalize_path( $path ) ), $environment['action_scheduler']['source_path'] );
+		$this->assertTrue( $environment['action_scheduler']['is_risky'] );
+	}
+
+	/**
+	 * @testdox Should report null Action Scheduler details and a risky state when no copy is loaded.
+	 */
+	public function test_get_environment_info_reports_no_action_scheduler_when_none_is_loaded(): void {
+		$this->register_legacy_proxy_function_mocks(
+			array( 'function_exists' => fn( string $name ) => 0 !== strpos( $name, 'as_' ) && 0 !== strpos( $name, 'wc_schedule' ) && function_exists( $name ) )
+		);
+
+		$environment = $this->sut->get_environment_info_per_fields( array() );
+
+		$this->assertNull( $environment['action_scheduler']['loaded_version'] );
+		$this->assertNull( $environment['action_scheduler']['source_type'] );
+		$this->assertNull( $environment['action_scheduler']['source_name'] );
+		$this->assertNull( $environment['action_scheduler']['source_path'] );
+		$this->assertTrue( $environment['action_scheduler']['is_risky'] );
+	}
+
+	/**
+	 * Make ActionSchedulerUtil see a loaded Action Scheduler copy of the given version, bundled by a plugin.
+	 *
+	 * Writes a plugin file with that version header to a temp directory, reports that directory as the
+	 * loaded path, and registers it as the real path of a plugin directory, as WordPress does for a
+	 * symlinked plugin, so the source resolves to that plugin. The feature probes still see the bundled
+	 * functions; a version header before 4.0.0 is enough to make the copy risky.
+	 *
+	 * @param string $version The version to simulate.
+	 *
+	 * @return string The directory of the simulated copy, without a trailing slash.
+	 */
+	private function simulate_loaded_action_scheduler( string $version ): string {
+		$dir = untrailingslashit( get_temp_dir() ) . '/wc-ssr-as-' . wp_generate_password( 8, false );
+		wp_mkdir_p( $dir );
+		$this->fake_action_scheduler_file = $dir . '/action-scheduler.php';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		file_put_contents( $this->fake_action_scheduler_file, "<?php\n/**\n * Plugin Name: Action Scheduler\n * Version: {$version}\n */\n" );
+
+		$this->register_legacy_proxy_static_mocks( array( 'ActionScheduler' => array( 'plugin_path' => fn( string $relative ) => $relative ? "$dir/$relative" : $dir ) ) );
+		$this->register_legacy_proxy_global_mocks( array( 'wp_plugin_paths' => array( wp_normalize_path( WP_PLUGIN_DIR ) . '/' . self::FAKE_PLUGIN_DIR => $dir ) ) );
+		ActionSchedulerUtil::reset_cache();
+
+		return $dir;
+	}
 }