Commit 71822a51074 for woocommerce
commit 71822a51074b9c3d1de9226f312e195afa82b03c
Author: Francesco <frosso@users.noreply.github.com>
Date: Wed Nov 11 10:55:10 2026 +0100
Track extension abilities registered through WooCommerce (#70478)
diff --git a/plugins/woocommerce/changelog/track-extension-ability-usage b/plugins/woocommerce/changelog/track-extension-ability-usage
new file mode 100644
index 00000000000..f741c860188
--- /dev/null
+++ b/plugins/woocommerce/changelog/track-extension-ability-usage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Record calls to abilities that extensions register through WooCommerce in usage tracking, when the store allows it.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index fe4e7666ece..0e7573d7779 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -75,11 +75,11 @@ class AbilitiesLoader {
private const LOG_SOURCE = 'woocommerce-abilities';
/**
- * Core ability instances registered by this loader in the current request.
+ * Ability instances registered by this loader in the current request.
*
* @var array<string, object>
*/
- private static array $registered_core_abilities = array();
+ private static array $registered_abilities = array();
/**
* Query var that holds the extension filter clauses of a list ability.
@@ -214,8 +214,8 @@ class AbilitiesLoader {
if (
$is_core_ability
- && isset( self::$registered_core_abilities[ $ability_name ] )
- && $existing_ability === self::$registered_core_abilities[ $ability_name ]
+ && isset( self::$registered_abilities[ $ability_name ] )
+ && $existing_ability === self::$registered_abilities[ $ability_name ]
) {
continue;
}
@@ -225,7 +225,7 @@ class AbilitiesLoader {
}
// Drop stale instance tracking before replacing a shadowed registration.
- unset( self::$registered_core_abilities[ $ability_name ] );
+ unset( self::$registered_abilities[ $ability_name ] );
wp_unregister_ability( $ability_name );
self::log_replaced_reserved_ability( $ability_name, $class_name );
}
@@ -239,12 +239,32 @@ class AbilitiesLoader {
$registered_ability = wp_register_ability( $ability_name, $args );
- if ( $is_core_ability && null !== $registered_ability ) {
- self::$registered_core_abilities[ $ability_name ] = $registered_ability;
+ if ( null !== $registered_ability ) {
+ self::$registered_abilities[ $ability_name ] = $registered_ability;
}
}
}
+ /**
+ * Whether the current ability instance was registered by this loader.
+ *
+ * @internal
+ * @since 11.4.0
+ *
+ * @param string $ability_name Ability name.
+ * @return bool
+ */
+ public static function is_registered_ability( string $ability_name ): bool {
+ if ( ! isset( self::$registered_abilities[ $ability_name ] ) ) {
+ return false;
+ }
+ if ( ! wp_has_ability( $ability_name ) ) {
+ return false;
+ }
+
+ return wp_get_ability( $ability_name ) === self::$registered_abilities[ $ability_name ];
+ }
+
/**
* Log when WooCommerce replaces a pre-existing registration in its reserved namespace.
*
@@ -303,6 +323,7 @@ class AbilitiesLoader {
* core classes are always retained.
*
* @since 10.9.0
+ * @since 11.4.0 Calls to abilities registered through this filter are recorded in usage tracking when the store allows it.
*
* @param array<int, class-string> $classes Ability definition class names.
*/
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
index 3498200caec..2f222df7957 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
@@ -14,7 +14,7 @@ use WP_REST_Request;
defined( 'ABSPATH' ) || exit;
/**
- * Records a Tracks event for each WooCommerce ability invocation.
+ * Records Tracks events for WooCommerce abilities, including extension abilities registered through its loader.
*
* Hooks only collect data in memory. Events are sent once per request on shutdown through the batched
* Tracks client. Ability input and output are never recorded, except the name of a registered ability an agent
@@ -50,7 +50,7 @@ class AbilitiesTracking {
private const TRACKED_CHANNELS = array( 'mcp', 'rest', 'cli' );
/**
- * Only abilities in this namespace are tracked.
+ * Abilities in this namespace are always tracked.
*
* @var string
*/
@@ -922,13 +922,21 @@ class AbilitiesTracking {
}
/**
- * Whether an ability is in the tracked namespace.
+ * Whether an ability uses WooCommerce's namespace or was registered through its loader.
*
* @param mixed $ability_name The ability name.
* @return bool
*/
private static function is_tracked_ability( $ability_name ): bool {
- return is_string( $ability_name ) && 0 === strpos( $ability_name, self::ABILITY_PREFIX );
+ if ( ! is_string( $ability_name ) ) {
+ return false;
+ }
+
+ if ( 0 === strpos( $ability_name, self::ABILITY_PREFIX ) ) {
+ return true;
+ }
+
+ return AbilitiesLoader::is_registered_ability( $ability_name );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
index c4b4f423e9d..d77f8366211 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesTracking;
use WC_Unit_Test_Case;
use WP_Error;
@@ -136,7 +137,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
}
// The abilities registry is a singleton that outlives the test.
- foreach ( array_merge( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID ), $this->registered_adapter_ids ) as $ability_id ) {
+ foreach ( array_merge( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID, TestExtensionAbilityDefinition::ABILITY_ID ), $this->registered_adapter_ids ) as $ability_id ) {
if ( wp_has_ability( $ability_id ) ) {
wp_unregister_ability( $ability_id );
}
@@ -175,6 +176,123 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'error', $event, 'A property named error makes Tracks drop the event' );
}
+ /**
+ * @testdox Should record calls to extension abilities registered through the loader.
+ */
+ public function test_records_loader_registered_extension_calls(): void {
+ $this->register_extension_ability();
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded, 'The extension call should be tracked once' );
+ $event = $this->sut->recorded[0];
+ $this->assertSame( 'wcadmin_ability_called', $event['_en'] );
+ $this->assertSame( 'test-extension__test-extension-ability', $event['ability_name'] );
+ $this->assertSame( 'success', $event['outcome'] );
+ $this->assertSame( 'none', $event['error_code'] );
+ $this->assertSame( 'mcp', $event['channel'] );
+ }
+
+ /**
+ * @testdox Should not track extensions registered directly with WordPress.
+ */
+ public function test_does_not_track_extensions_registered_directly(): void {
+ $this->register_extension_ability( false );
+ $this->start_mcp_tool_call();
+
+ $result = wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+ wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+ $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber_id );
+ $response = rest_do_request( new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . TestExtensionAbilityDefinition::ABILITY_ID . '/run' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertNotWPError( $result, 'The extension remains usable outside the WooCommerce loader' );
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertCount( 0, $this->sut->recorded, 'Public discovery and a matching category do not make an ability loader-owned' );
+ }
+
+ /**
+ * @testdox Should record a loader-registered extension's REST permission rejection before execution.
+ */
+ public function test_records_extension_rest_rejection_before_execution(): void {
+ $this->register_extension_ability();
+ $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+ wp_set_current_user( $subscriber_id );
+
+ $response = rest_do_request( new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . TestExtensionAbilityDefinition::ABILITY_ID . '/run' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'test-extension__test-extension-ability', $this->sut->recorded[0]['ability_name'] );
+ $this->assertSame( 'rest', $this->sut->recorded[0]['channel'] );
+ $this->assertSame( 'rest_ability_cannot_execute', $this->sut->recorded[0]['error_code'] );
+ $this->assertArrayNotHasKey( 'duration_ms', $this->sut->recorded[0] );
+ }
+
+ /**
+ * @testdox Should not track an extension ability replaced outside the loader.
+ */
+ public function test_does_not_track_replaced_extension_ability(): void {
+ $this->register_extension_ability();
+ wp_unregister_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+ $this->register_extension_ability( false );
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+ wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 0, $this->sut->recorded, 'Only the ability instance registered by the loader belongs to WooCommerce tracking' );
+ }
+
+ /**
+ * @testdox Should not adopt a pre-existing extension ability when its definition is added to the loader.
+ */
+ public function test_does_not_track_preexisting_extension_ability(): void {
+ $this->register_extension_ability( false );
+ $this->register_extension_ability();
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+ wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 0, $this->sut->recorded, 'A definition in the loader does not make an existing registration loader-owned' );
+ }
+
+ /**
+ * @testdox Should preserve extension tracking when the loader runs more than once.
+ */
+ public function test_tracks_extension_after_repeated_loader_registration(): void {
+ $this->register_extension_ability();
+ $this->register_extension_ability();
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'test-extension__test-extension-ability', $this->sut->recorded[0]['ability_name'] );
+ }
+
+ /**
+ * @testdox Should ignore discovery of a removed extension ability without looking up a missing registration.
+ */
+ public function test_does_not_track_unregistered_extension_ability(): void {
+ $this->register_extension_ability();
+ wp_unregister_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
/**
* @testdox Should not send ability input, including input carried in the request URL or referrer.
*/
@@ -396,6 +514,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
/**
* @testdox Should record MCP Adapter discovery of WooCommerce abilities as a separate event.
* @testWith ["mcp-adapter/get-ability-info", "woocommerce/tracking-test", true, "info", "woocommerce__tracking-test"]
+ * ["mcp-adapter/get-ability-info", "test-extension/test-extension-ability", true, "info", "test-extension__test-extension-ability"]
* ["mcp-adapter/discover-abilities", "", true, "list", "none"]
* ["mcp-adapter/get-ability-info", "tracking-test/other", true, "", ""]
* ["mcp-adapter/get-ability-info", "woocommerce/not-registered", true, "", ""]
@@ -408,6 +527,9 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
* @param string $recorded_ability Expected ability_name.
*/
public function test_records_adapter_discovery( string $adapter_ability, string $looked_up, bool $over_mcp, string $discovery, string $recorded_ability ): void {
+ if ( TestExtensionAbilityDefinition::ABILITY_ID === $looked_up ) {
+ $this->register_extension_ability();
+ }
if ( $over_mcp ) {
$this->start_mcp_tool_call();
}
@@ -794,7 +916,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should not record abilities outside the woocommerce namespace.
+ * @testdox Should not record abilities outside the woocommerce namespace that the loader did not register.
*/
public function test_ignores_abilities_outside_woocommerce_namespace(): void {
$this->start_mcp_tool_call();
@@ -1135,6 +1257,32 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
apply_filters( 'mcp_adapter_pre_tool_call', array(), 'mcp-adapter/execute-ability', null, null );
}
+ /**
+ * Register the extension fixture through WooCommerce or directly with WordPress.
+ *
+ * @param bool $through_loader Whether to use the WooCommerce loader.
+ */
+ private function register_extension_ability( bool $through_loader = true ): void {
+ $definitions = static function ( $classes ) {
+ $classes[] = TestExtensionAbilityDefinition::class;
+ return $classes;
+ };
+ $register = $through_loader ? array( AbilitiesLoader::class, 'register_abilities' ) : static function (): void {
+ wp_register_ability( TestExtensionAbilityDefinition::ABILITY_ID, TestExtensionAbilityDefinition::get_registration_args() );
+ };
+
+ if ( $through_loader ) {
+ add_filter( 'woocommerce_ability_definition_classes', $definitions );
+ }
+ add_action( 'wp_abilities_api_init', $register );
+ try {
+ do_action( 'wp_abilities_api_init' );
+ } finally {
+ remove_action( 'wp_abilities_api_init', $register );
+ remove_filter( 'woocommerce_ability_definition_classes', $definitions );
+ }
+ }
+
/**
* Skip the test on WordPress versions without hooks for every ability outcome (before 7.1).
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
index b3a19084c5b..c7c523ec7c3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
@@ -47,7 +47,7 @@ class TestExtensionAbilityDefinition implements AbilityDefinition {
),
'additionalProperties' => false,
),
- 'permission_callback' => '__return_true',
+ 'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ),
'meta' => array(
'show_in_rest' => true,
'mcp' => array(