From 1fe99e15aa5e8fd09502be851d642a6d06bef8d7 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Mon, 18 May 2026 17:02:12 +0530 Subject: [PATCH] Abilities API: Add wp_ability_invoked action for pipeline-entry observers --- .../abilities-api/class-wp-ability.php | 34 ++++- .../phpunit/tests/abilities-api/wpAbility.php | 137 +++++++++++++++++- 2 files changed, 156 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 500c2584070c5..fd1eefc1534b0 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -725,12 +725,28 @@ protected function validate_output( $output ) { * Before returning the return value, it also validates the output. * * @since 6.9.0 + * @since 7.1.0 Added the `wp_ability_invoked` action. * @since 7.1.0 Added the `wp_pre_execute_ability` filter. * * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { + /** + * Fires when an ability is invoked, before any processing takes place. + * + * This action fires for every call regardless of outcome (validation failure, + * permission denial, short-circuit, or successful execution), and before input + * normalization so the raw input is captured as-is. + * + * @since 7.1.0 + * + * @param string $ability_name The name of the ability. + * @param mixed $input The raw input data for the ability, before normalization. + * @param WP_Ability $ability The ability instance. + */ + do_action( 'wp_ability_invoked', $this->name, $input, $this ); + /** * Filters whether to short-circuit ability execution. * @@ -791,11 +807,13 @@ public function execute( $input = null ) { * Fires before an ability gets executed, after input validation and permissions check. * * @since 6.9.0 + * @since 7.1.0 Added the `$ability` parameter. * - * @param string $ability_name The name of the ability. - * @param mixed $input The input data for the ability. + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + * @param WP_Ability $ability The ability instance. */ - do_action( 'wp_before_execute_ability', $this->name, $input ); + do_action( 'wp_before_execute_ability', $this->name, $input, $this ); $result = $this->do_execute( $input ); if ( is_wp_error( $result ) ) { @@ -811,12 +829,14 @@ public function execute( $input = null ) { * Fires immediately after an ability finished executing. * * @since 6.9.0 + * @since 7.1.0 Added the `$ability` parameter. * - * @param string $ability_name The name of the ability. - * @param mixed $input The input data for the ability. - * @param mixed $result The result of the ability execution. + * @param string $ability_name The name of the ability. + * @param mixed $input The input data for the ability. + * @param mixed $result The result of the ability execution. + * @param WP_Ability $ability The ability instance. */ - do_action( 'wp_after_execute_ability', $this->name, $input, $result ); + do_action( 'wp_after_execute_ability', $this->name, $input, $result, $this ); return $result; } diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 73c4f9db43ffd..c19efc7f1ee56 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -553,6 +553,7 @@ public function test_check_permissions_catches_callback_exception() { public function test_before_execute_ability_action() { $action_ability_name = null; $action_input = null; + $action_ability = null; $args = array_merge( self::$test_ability_properties, @@ -570,12 +571,13 @@ public function test_before_execute_ability_action() { add_action( 'wp_before_execute_ability', - static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + static function ( $ability_name, $input, $ability ) use ( &$action_ability_name, &$action_input, &$action_ability ) { $action_ability_name = $ability_name; $action_input = $input; + $action_ability = $ability; }, 10, - 2 + 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -583,6 +585,7 @@ static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_ $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 5, $action_input, 'Action should receive correct input' ); + $this->assertSame( $ability, $action_ability, 'Action should receive the ability instance' ); $this->assertSame( 10, $result, 'Ability should execute correctly' ); } @@ -594,6 +597,7 @@ static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_ public function test_before_execute_ability_action_no_input() { $action_ability_name = null; $action_input = null; + $action_ability = null; $args = array_merge( self::$test_ability_properties, @@ -606,12 +610,13 @@ public function test_before_execute_ability_action_no_input() { add_action( 'wp_before_execute_ability', - static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) { + static function ( $ability_name, $input, $ability ) use ( &$action_ability_name, &$action_input, &$action_ability ) { $action_ability_name = $ability_name; $action_input = $input; + $action_ability = $ability; }, 10, - 2 + 3 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -619,6 +624,7 @@ static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_ $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); + $this->assertSame( $ability, $action_ability, 'Action should receive the ability instance' ); $this->assertSame( 42, $result, 'Ability should execute correctly' ); } @@ -631,6 +637,7 @@ public function test_after_execute_ability_action() { $action_ability_name = null; $action_input = null; $action_result = null; + $action_ability = null; $args = array_merge( self::$test_ability_properties, @@ -648,13 +655,14 @@ public function test_after_execute_ability_action() { add_action( 'wp_after_execute_ability', - static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + static function ( $ability_name, $input, $result, $ability ) use ( &$action_ability_name, &$action_input, &$action_result, &$action_ability ) { $action_ability_name = $ability_name; $action_input = $input; $action_result = $result; + $action_ability = $ability; }, 10, - 3 + 4 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -663,6 +671,7 @@ static function ( $ability_name, $input, $result ) use ( &$action_ability_name, $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertSame( 7, $action_input, 'Action should receive correct input' ); $this->assertSame( 21, $action_result, 'Action should receive correct result' ); + $this->assertSame( $ability, $action_ability, 'Action should receive the ability instance' ); $this->assertSame( 21, $result, 'Ability should execute correctly' ); } @@ -675,6 +684,7 @@ public function test_after_execute_ability_action_no_input() { $action_ability_name = null; $action_input = null; $action_result = null; + $action_ability = null; $args = array_merge( self::$test_ability_properties, @@ -688,13 +698,14 @@ public function test_after_execute_ability_action_no_input() { add_action( 'wp_after_execute_ability', - static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) { + static function ( $ability_name, $input, $result, $ability ) use ( &$action_ability_name, &$action_input, &$action_result, &$action_ability ) { $action_ability_name = $ability_name; $action_input = $input; $action_result = $result; + $action_ability = $ability; }, 10, - 3 + 4 ); $ability = new WP_Ability( self::$test_ability_name, $args ); @@ -703,6 +714,7 @@ static function ( $ability_name, $input, $result ) use ( &$action_ability_name, $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' ); $this->assertNull( $action_input, 'Action should receive null input when no input provided' ); $this->assertSame( 'test-result', $action_result, 'Action should receive correct result' ); + $this->assertSame( $ability, $action_ability, 'Action should receive the ability instance' ); $this->assertSame( 'test-result', $result, 'Ability should execute correctly' ); } @@ -1696,4 +1708,113 @@ static function () { $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'custom_output_error', $result->get_error_code() ); } + + /** + * Tests that wp_ability_invoked action fires with correct parameters and raw input before normalization. + * + * @ticket 65248 + */ + public function test_ability_invoked_action_fires_with_correct_params() { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input parameter.', + 'default' => 42, + ), + 'execute_callback' => static function ( int $input ): int { + return $input; + }, + ) + ); + + $action = new MockAction(); + add_action( 'wp_ability_invoked', array( $action, 'action' ), 10, 3 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $ability->execute(); + + $action_args = $action->get_args(); + $this->assertSame( self::$test_ability_name, $action_args[0][0], 'Action should receive correct ability name.' ); + $this->assertNull( $action_args[0][1], 'Action should receive raw null input, not the schema default.' ); + $this->assertSame( $ability, $action_args[0][2], 'Action should receive the ability instance.' ); + } + + /** + * Tests that wp_ability_invoked action fires when execution is short-circuited. + * + * @ticket 65248 + */ + public function test_ability_invoked_action_fires_on_pre_execute_short_circuit() { + $action = new MockAction(); + add_action( 'wp_ability_invoked', array( $action, 'action' ) ); + + add_filter( + 'wp_pre_execute_ability', + static function () { + return 'short-circuited'; + } + ); + + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $ability->execute(); + + $this->assertSame( 1, $action->get_call_count(), 'wp_ability_invoked should fire before a pre-execute short-circuit.' ); + } + + /** + * Tests that wp_ability_invoked action fires on permission failure. + * + * @ticket 65248 + */ + public function test_ability_invoked_action_fires_on_permission_failure() { + $action = new MockAction(); + add_action( 'wp_ability_invoked', array( $action, 'action' ) ); + + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'permission_callback' => static function (): bool { + return false; + }, + ) + ) + ); + $ability->execute(); + + $this->assertSame( 1, $action->get_call_count(), 'wp_ability_invoked should fire before permission failure.' ); + } + + /** + * Tests that wp_ability_invoked action fires on input validation failure. + * + * @ticket 65248 + */ + public function test_ability_invoked_action_fires_on_validation_failure() { + $action = new MockAction(); + add_action( 'wp_ability_invoked', array( $action, 'action' ) ); + + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Int input.', + 'required' => true, + ), + 'execute_callback' => static function ( int $input ): int { + return $input; + }, + ) + ) + ); + $ability->execute( 'not_an_integer' ); + + $this->assertSame( 1, $action->get_call_count(), 'wp_ability_invoked should fire before input validation failure.' ); + } }