<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[61032] trunk: Abilities API: Introduce server-side registry and REST API endpoints</title>
</head>
<body>
<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; }
#msg dl a { font-weight: bold}
#msg dl a:link { color:#fc3; }
#msg dl a:active { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/61032">61032</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/61032","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>gziolo</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2025-10-21 13:50:11 +0000 (Tue, 21 Oct 2025)</dd>
</dl>
<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Abilities API: Introduce server-side registry and REST API endpoints
Feature proposal at https://make.wordpress.org/ai/2025/07/17/abilities-api/.
Project developed in https://github.com/WordPress/abilities-api.
Introduces a new Abilities API that allows WordPress plugins and themes to register and execute custom abilities with built-in permission checking, input/output validation via JSON Schema, and REST API integration.
## Public Functions
### Ability Management
- `wp_register_ability( string $name, array $args ): ?WP_Ability` - Registers a new ability (must be called on `wp_abilities_api_init` hook)
- `wp_unregister_ability( string $name ): ?WP_Ability` - Unregisters an ability
- `wp_has_ability( string $name ): bool` - Checks if an ability is registered
- `wp_get_ability( string $name ): ?WP_Ability` - Retrieves a registered ability
- `wp_get_abilities(): array` - Retrieves all registered abilities
### Ability Category Management
- `wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category` - Registers an ability category (must be called on `wp_abilities_api_categories_init` hook)
- `wp_unregister_ability_category( string $slug ): ?WP_Ability_Category` - Unregisters an ability category
- `wp_has_ability_category( string $slug ): bool` - Checks if an ability category is registered
- `wp_get_ability_category( string $slug ): ?WP_Ability_Category` - Retrieves a registered ability category
- `wp_get_ability_categories(): array` - Retrieves all registered ability categories
## Public Classes
- `WP_Ability` - Encapsulates ability properties and methods (execute, check_permission, validate_input, etc.)
- `WP_Ability_Category` - Encapsulates ability category properties
- `WP_Abilities_Registry` - Manages ability registration and lookup (private, accessed via functions)
- `WP_Ability_Categories_Registry` - Manages ability category registration (private, accessed via functions)
- `WP_REST_Abilities_V1_List_Controller` - REST controller for listing abilities
- `WP_REST_Abilities_V1_Run_Controller` - REST controller for executing abilities
## REST API Endpoints
### Namespace: `wp-abilities/v1`
#### List Abilities
- `GET /wp-abilities/v1/abilities` - Retrieve all registered abilities
- Query parameters: `page`, `per_page`, `category`
#### Get Single Ability
- `GET /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)` - Retrieve a specific ability by name
#### Execute Ability
- `GET|POST|DELETE /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)/run` - Execute an ability
- Supports multiple HTTP methods based on ability annotations
- Validates input against ability's input schema
- Validates output against ability's output schema
- Performs permission checks via ability's permission callback
## Hooks
### Actions
- `wp_abilities_api_categories_init` - Fired when ability categories registry is initialized (register categories here)
- `wp_abilities_api_init` - Fired when abilities registry is initialized (register abilities here)
- `wp_before_execute_ability` - Fired before an ability gets executed, after input validation and permissions check
- `wp_after_execute_ability` - Fires immediately after an ability finished executing
### Filters
- `wp_register_ability_category_args` - Filters ability category arguments before registration
- `wp_register_ability_args` - Filters ability arguments before registration
Developed in https://github.com/WordPress/wordpress-develop/pull/9410.
Props gziolo, jorbin, justlevine, westonruter, jason_the_adams, flixos90, karmatosed, timothyblynjacobs.
Fixes <a href="https://core.trac.wordpress.org/ticket/64098">#64098</a>.</pre>
<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesrestapiphp">trunk/src/wp-includes/rest-api.php</a></li>
<li><a href="#trunksrcwpsettingsphp">trunk/src/wp-settings.php</a></li>
<li><a href="#trunktestsphpunittestsrestapirestschemasetupphp">trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php</a></li>
<li><a href="#trunktestsqunitfixtureswpapigeneratedjs">trunk/tests/qunit/fixtures/wp-api-generated.js</a></li>
</ul>
<h3>Added Paths</h3>
<ul>
<li>trunk/src/wp-includes/abilities-api/</li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitiesregistryphp">trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitycategoriesregistryphp">trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitycategoryphp">trunk/src/wp-includes/abilities-api/class-wp-ability-category.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilityphp">trunk/src/wp-includes/abilities-api/class-wp-ability.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiphp">trunk/src/wp-includes/abilities-api.php</a></li>
<li><a href="#trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1listcontrollerphp">trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php</a></li>
<li><a href="#trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1runcontrollerphp">trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php</a></li>
<li>trunk/tests/phpunit/tests/abilities-api/</li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilitiesRegistryphp">trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilityphp">trunk/tests/phpunit/tests/abilities-api/wpAbility.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilityCategoryRegistryphp">trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpRegisterAbilityphp">trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpRegisterAbilityCategoryphp">trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php</a></li>
<li><a href="#trunktestsphpunittestsrestapiwpRestAbilitiesV1ListControllerphp">trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php</a></li>
<li><a href="#trunktestsphpunittestsrestapiwpRestAbilitiesV1RunControllerphp">trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php</a></li>
</ul>
</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesabilitiesapiclasswpabilitiesregistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,320 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Abilities_Registry class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Manages the registration and lookup of abilities.
+ *
+ * @since 6.9.0
+ * @access private
+ */
+final class WP_Abilities_Registry {
+ /**
+ * The singleton instance of the registry.
+ *
+ * @since 6.9.0
+ * @var self|null
+ */
+ private static $instance = null;
+
+ /**
+ * Holds the registered abilities.
+ *
+ * @since 6.9.0
+ * @var WP_Ability[]
+ */
+ private $registered_abilities = array();
+
+ /**
+ * Registers a new ability.
+ *
+ * Do not use this method directly. Instead, use the `wp_register_ability()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_register_ability()
+ *
+ * @param string $name The name of the ability. The name must be a string containing a namespace
+ * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
+ * alphanumeric characters, dashes and the forward slash.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Optional. Additional metadata for the ability.
+ *
+ * @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ * }
+ * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
+ * }
+ * @return WP_Ability|null The registered ability instance on success, null on failure.
+ */
+ public function register( string $name, array $args ): ?WP_Ability {
+ if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __(
+ 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.'
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ if ( $this->is_registered( $name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability name. */
+ sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ /**
+ * Filters the ability arguments before they are validated and used to instantiate the ability.
+ *
+ * @since 6.9.0
+ *
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Optional. Additional metadata for the ability.
+ *
+ * @type array<string, bool|string> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ * }
+ * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
+ * }
+ * @param string $name The name of the ability, with its namespace.
+ */
+ $args = apply_filters( 'wp_register_ability_args', $args, $name );
+
+ // Validate ability category exists if provided (will be validated as required in WP_Ability).
+ if ( isset( $args['category'] ) ) {
+ $category_registry = WP_Ability_Categories_Registry::get_instance();
+ if ( ! $category_registry->is_registered( $args['category'] ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %1$s: ability category slug, %2$s: ability name */
+ __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ),
+ esc_html( $args['category'] ),
+ esc_html( $name )
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+ }
+
+ // The class is only used to instantiate the ability, and is not a property of the ability itself.
+ if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ /** @var class-string<WP_Ability> */
+ $ability_class = $args['ability_class'] ?? WP_Ability::class;
+ unset( $args['ability_class'] );
+
+ try {
+ // WP_Ability::prepare_properties() will throw an exception if the properties are invalid.
+ $ability = new $ability_class( $name, $args );
+ } catch ( InvalidArgumentException $e ) {
+ _doing_it_wrong(
+ __METHOD__,
+ $e->getMessage(),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $this->registered_abilities[ $name ] = $ability;
+ return $ability;
+ }
+
+ /**
+ * Unregisters an ability.
+ *
+ * Do not use this method directly. Instead, use the `wp_unregister_ability()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_unregister_ability()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return WP_Ability|null The unregistered ability instance on success, null on failure.
+ */
+ public function unregister( string $name ): ?WP_Ability {
+ if ( ! $this->is_registered( $name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability name. */
+ sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $unregistered_ability = $this->registered_abilities[ $name ];
+ unset( $this->registered_abilities[ $name ] );
+
+ return $unregistered_ability;
+ }
+
+ /**
+ * Retrieves the list of all registered abilities.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_abilities()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_get_abilities()
+ *
+ * @return WP_Ability[] The array of registered abilities.
+ */
+ public function get_all_registered(): array {
+ return $this->registered_abilities;
+ }
+
+ /**
+ * Checks if an ability is registered.
+ *
+ * Do not use this method directly. Instead, use the `wp_has_ability()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_has_ability()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return bool True if the ability is registered, false otherwise.
+ */
+ public function is_registered( string $name ): bool {
+ return isset( $this->registered_abilities[ $name ] );
+ }
+
+ /**
+ * Retrieves a registered ability.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_ability()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_get_ability()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return ?WP_Ability The registered ability instance, or null if it is not registered.
+ */
+ public function get_registered( string $name ): ?WP_Ability {
+ if ( ! $this->is_registered( $name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability name. */
+ sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+ return $this->registered_abilities[ $name ];
+ }
+
+ /**
+ * Utility method to retrieve the main instance of the registry class.
+ *
+ * The instance will be created if it does not exist yet.
+ *
+ * @since 6.9.0
+ *
+ * @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired.
+ */
+ public static function get_instance(): ?self {
+ if ( ! did_action( 'init' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ __( 'Ability API should not be initialized before the <code>init</code> action has fired' )
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+
+ // Ensure ability category registry is initialized first to allow categories to be registered
+ // before abilities that depend on them.
+ WP_Ability_Categories_Registry::get_instance();
+
+ /**
+ * Fires when preparing abilities registry.
+ *
+ * Abilities should be created and register their hooks on this action rather
+ * than another action to ensure they're only loaded when needed.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_Abilities_Registry $instance Abilities registry object.
+ */
+ do_action( 'wp_abilities_api_init', self::$instance );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the registry object is unserialized.
+ * This is a security hardening measure to prevent unserialization of the registry.
+ */
+ public function __wakeup(): void {
+ throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+ }
+
+ /**
+ * Sleep magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the registry object is serialized.
+ * This is a security hardening measure to prevent serialization of the registry.
+ */
+ public function __sleep(): array {
+ throw new LogicException( __CLASS__ . ' should never be serialized' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilitycategoriesregistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,254 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability_Categories_Registry class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Manages the registration and lookup of ability categories.
+ *
+ * @since 6.9.0
+ * @access private
+ */
+final class WP_Ability_Categories_Registry {
+ /**
+ * The singleton instance of the registry.
+ *
+ * @since 6.9.0
+ * @var self|null
+ */
+ private static $instance = null;
+
+ /**
+ * Holds the registered ability categories.
+ *
+ * @since 6.9.0
+ * @var WP_Ability_Category[]
+ */
+ private $registered_categories = array();
+
+ /**
+ * Registers a new ability category.
+ *
+ * Do not use this method directly. Instead, use the `wp_register_ability_category()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_register_ability_category()
+ *
+ * @param string $slug The unique slug for the ability category. Must contain only lowercase
+ * alphanumeric characters and dashes.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability category.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ * @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
+ */
+ public function register( string $slug, array $args ): ?WP_Ability_Category {
+ if ( $this->is_registered( $slug ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability category slug. */
+ sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ __( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ /**
+ * Filters the ability category arguments before they are validated and used to instantiate the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @param array<string, mixed> $args {
+ * The arguments used to instantiate the ability category.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ * @param string $slug The slug of the ability category.
+ */
+ $args = apply_filters( 'wp_register_ability_category_args', $args, $slug );
+
+ try {
+ // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid.
+ $category = new WP_Ability_Category( $slug, $args );
+ } catch ( InvalidArgumentException $e ) {
+ _doing_it_wrong(
+ __METHOD__,
+ $e->getMessage(),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $this->registered_categories[ $slug ] = $category;
+ return $category;
+ }
+
+ /**
+ * Unregisters an ability category.
+ *
+ * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_unregister_ability_category()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
+ */
+ public function unregister( string $slug ): ?WP_Ability_Category {
+ if ( ! $this->is_registered( $slug ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability category slug. */
+ sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $unregistered_category = $this->registered_categories[ $slug ];
+ unset( $this->registered_categories[ $slug ] );
+
+ return $unregistered_category;
+ }
+
+ /**
+ * Retrieves the list of all registered ability categories.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_get_ability_categories()
+ *
+ * @return array<string, WP_Ability_Category> The array of registered ability categories.
+ */
+ public function get_all_registered(): array {
+ return $this->registered_categories;
+ }
+
+ /**
+ * Checks if an ability category is registered.
+ *
+ * Do not use this method directly. Instead, use the `wp_has_ability_category()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_has_ability_category()
+ *
+ * @param string $slug The slug of the ability category.
+ * @return bool True if the ability category is registered, false otherwise.
+ */
+ public function is_registered( string $slug ): bool {
+ return isset( $this->registered_categories[ $slug ] );
+ }
+
+ /**
+ * Retrieves a registered ability category.
+ *
+ * Do not use this method directly. Instead, use the `wp_get_ability_category()` function.
+ *
+ * @since 6.9.0
+ *
+ * @see wp_get_ability_category()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
+ */
+ public function get_registered( string $slug ): ?WP_Ability_Category {
+ if ( ! $this->is_registered( $slug ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ /* translators: %s: Ability category slug. */
+ sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
+ '6.9.0'
+ );
+ return null;
+ }
+ return $this->registered_categories[ $slug ];
+ }
+
+ /**
+ * Utility method to retrieve the main instance of the registry class.
+ *
+ * The instance will be created if it does not exist yet.
+ *
+ * @since 6.9.0
+ *
+ * @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired.
+ */
+ public static function get_instance(): ?self {
+ if ( ! did_action( 'init' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ __( 'Ability API should not be initialized before the <code>init</code> action has fired' )
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+
+ /**
+ * Fires when preparing ability categories registry.
+ *
+ * Ability categories should be registered on this action to ensure they're available when needed.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_Ability_Categories_Registry $instance Ability categories registry object.
+ */
+ do_action( 'wp_abilities_api_categories_init', self::$instance );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the registry object is unserialized.
+ * This is a security hardening measure to prevent unserialization of the registry.
+ */
+ public function __wakeup(): void {
+ throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+ }
+
+ /**
+ * Sleep magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the registry object is serialized.
+ * This is a security hardening measure to prevent serialization of the registry.
+ */
+ public function __sleep(): array {
+ throw new LogicException( __CLASS__ . ' should never be serialized' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilitycategoryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability-category.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability-category.php (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability-category.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,216 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability_Category class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Encapsulates the properties and methods related to a specific ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry
+ */
+final class WP_Ability_Category {
+
+ /**
+ * The unique slug for the ability category.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $slug;
+
+ /**
+ * The human-readable ability category label.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The detailed ability category description.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * The optional ability category metadata.
+ *
+ * @since 6.9.0
+ * @var array<string, mixed>
+ */
+ protected $meta = array();
+
+ /**
+ * Constructor.
+ *
+ * Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function.
+ *
+ * @access private
+ *
+ * @since 6.9.0
+ *
+ * @see wp_register_ability_category()
+ *
+ * @param string $slug The unique slug for the ability category.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability category.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ */
+ public function __construct( string $slug, array $args ) {
+ if ( empty( $slug ) ) {
+ throw new InvalidArgumentException(
+ esc_html__( 'The ability category slug cannot be empty.' )
+ );
+ }
+
+ $this->slug = $slug;
+
+ $properties = $this->prepare_properties( $args );
+
+ foreach ( $properties as $property_name => $property_value ) {
+ if ( ! property_exists( $this, $property_name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: Property name. */
+ __( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ),
+ '<code>' . esc_html( $property_name ) . '</code>',
+ '<code>' . esc_html( $this->slug ) . '</code>',
+ '<code>' . __CLASS__ . '</code>'
+ ),
+ '6.9.0'
+ );
+ continue;
+ }
+
+ $this->$property_name = $property_value;
+ }
+ }
+
+ /**
+ * Prepares and validates the properties used to instantiate the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @param array<string, mixed> $args $args {
+ * An associative array of arguments used to instantiate the ability category class.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ * @return array<string, mixed> $args {
+ * An associative array with validated and prepared ability category properties.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ * @throws InvalidArgumentException if an argument is invalid.
+ */
+ protected function prepare_properties( array $args ): array {
+ // Required args must be present and of the correct type.
+ if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability category properties must contain a `label` string.' )
+ );
+ }
+
+ if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability category properties must contain a `description` string.' )
+ );
+ }
+
+ // Optional args only need to be of the correct type if they are present.
+ if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability category properties should provide a valid `meta` array.' )
+ );
+ }
+
+ return $args;
+ }
+
+ /**
+ * Retrieves the slug of the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @return string The ability category slug.
+ */
+ public function get_slug(): string {
+ return $this->slug;
+ }
+
+ /**
+ * Retrieves the human-readable label for the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @return string The human-readable ability category label.
+ */
+ public function get_label(): string {
+ return $this->label;
+ }
+
+ /**
+ * Retrieves the detailed description for the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @return string The detailed description for the ability category.
+ */
+ public function get_description(): string {
+ return $this->description;
+ }
+
+ /**
+ * Retrieves the metadata for the ability category.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string,mixed> The metadata for the ability category.
+ */
+ public function get_meta(): array {
+ return $this->meta;
+ }
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the ability category object is unserialized.
+ * This is a security hardening measure to prevent unserialization of the ability category.
+ */
+ public function __wakeup(): void {
+ throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+ }
+
+ /**
+ * Sleep magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the ability category object is serialized.
+ * This is a security hardening measure to prevent serialization of the ability category.
+ */
+ public function __sleep(): array {
+ throw new LogicException( __CLASS__ . ' should never be serialized' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability-category.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability.php (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,617 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Encapsulates the properties and methods related to a specific ability in the registry.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry
+ */
+class WP_Ability {
+
+ /**
+ * The default value for the `show_in_rest` meta.
+ *
+ * @since 6.9.0
+ * @var bool
+ */
+ protected const DEFAULT_SHOW_IN_REST = false;
+
+ /**
+ * The default ability annotations.
+ * They are not guaranteed to provide a faithful description of ability behavior.
+ *
+ * @since 6.9.0
+ * @var array<string, (null|bool)>
+ */
+ protected static $default_annotations = array(
+ // If true, the ability does not modify its environment.
+ 'readonly' => null,
+ /*
+ * If true, the ability may perform destructive updates to its environment.
+ * If false, the ability performs only additive updates.
+ */
+ 'destructive' => null,
+ /*
+ * If true, calling the ability repeatedly with the same arguments will have no additional effect
+ * on its environment.
+ */
+ 'idempotent' => null,
+ );
+
+ /**
+ * The name of the ability, with its namespace.
+ * Example: `my-plugin/my-ability`.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The human-readable ability label.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $label;
+
+ /**
+ * The detailed ability description.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * The ability category.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $category;
+
+ /**
+ * The optional ability input schema.
+ *
+ * @since 6.9.0
+ * @var array<string, mixed>
+ */
+ protected $input_schema = array();
+
+ /**
+ * The optional ability output schema.
+ *
+ * @since 6.9.0
+ * @var array<string, mixed>
+ */
+ protected $output_schema = array();
+
+ /**
+ * The ability execute callback.
+ *
+ * @since 6.9.0
+ * @var callable( mixed $input= ): (mixed|WP_Error)
+ */
+ protected $execute_callback;
+
+ /**
+ * The optional ability permission callback.
+ *
+ * @since 6.9.0
+ * @var callable( mixed $input= ): (bool|WP_Error)
+ */
+ protected $permission_callback;
+
+ /**
+ * The optional ability metadata.
+ *
+ * @since 6.9.0
+ * @var array<string, mixed>
+ */
+ protected $meta;
+
+ /**
+ * Constructor.
+ *
+ * Do not use this constructor directly. Instead, use the `wp_register_ability()` function.
+ *
+ * @access private
+ *
+ * @since 6.9.0
+ *
+ * @see wp_register_ability()
+ *
+ * @param string $name The name of the ability, with its namespace.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Optional. Additional metadata for the ability.
+ *
+ * @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ * }
+ * }
+ */
+ public function __construct( string $name, array $args ) {
+ $this->name = $name;
+
+ $properties = $this->prepare_properties( $args );
+
+ foreach ( $properties as $property_name => $property_value ) {
+ if ( ! property_exists( $this, $property_name ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: Property name. */
+ __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ),
+ '<code>' . esc_html( $property_name ) . '</code>',
+ '<code>' . esc_html( $this->name ) . '</code>',
+ '<code>' . self::class . '</code>'
+ ),
+ '6.9.0'
+ );
+ continue;
+ }
+
+ $this->$property_name = $property_value;
+ }
+ }
+
+ /**
+ * Prepares and validates the properties used to instantiate the ability.
+ *
+ * Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then
+ * caught and converted to a WP_Error when by WP_Abilities_Registry::register().
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::register()
+ *
+ * @param array<string, mixed> $args {
+ * An associative array of arguments used to instantiate the ability class.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Optional. Additional metadata for the ability.
+ *
+ * @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ * }
+ * }
+ * @return array<string, mixed> {
+ * An associative array of arguments with validated and prepared properties for the ability class.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Additional metadata for the ability.
+ *
+ * @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Whether to expose this ability in the REST API. Default false.
+ * }
+ * }
+ * @throws InvalidArgumentException if an argument is invalid.
+ */
+ protected function prepare_properties( array $args ): array {
+ // Required args must be present and of the correct type.
+ if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties must contain a `label` string.' )
+ );
+ }
+
+ if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties must contain a `description` string.' )
+ );
+ }
+
+ if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties must contain a `category` string.' )
+ );
+ }
+
+ if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties must contain a valid `execute_callback` function.' )
+ );
+ }
+
+ if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties must provide a valid `permission_callback` function.' )
+ );
+ }
+
+ // Optional args only need to be of the correct type if they are present.
+ if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties should provide a valid `input_schema` definition.' )
+ );
+ }
+
+ if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties should provide a valid `output_schema` definition.' )
+ );
+ }
+
+ if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability properties should provide a valid `meta` array.' )
+ );
+ }
+
+ if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability meta should provide a valid `annotations` array.' )
+ );
+ }
+
+ if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) {
+ throw new InvalidArgumentException(
+ __( 'The ability meta should provide a valid `show_in_rest` boolean.' )
+ );
+ }
+
+ // Set defaults for optional meta.
+ $args['meta'] = wp_parse_args(
+ $args['meta'] ?? array(),
+ array(
+ 'annotations' => static::$default_annotations,
+ 'show_in_rest' => self::DEFAULT_SHOW_IN_REST,
+ )
+ );
+ $args['meta']['annotations'] = wp_parse_args(
+ $args['meta']['annotations'],
+ static::$default_annotations
+ );
+
+ return $args;
+ }
+
+ /**
+ * Retrieves the name of the ability, with its namespace.
+ * Example: `my-plugin/my-ability`.
+ *
+ * @since 6.9.0
+ *
+ * @return string The ability name, with its namespace.
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ /**
+ * Retrieves the human-readable label for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return string The human-readable ability label.
+ */
+ public function get_label(): string {
+ return $this->label;
+ }
+
+ /**
+ * Retrieves the detailed description for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return string The detailed description for the ability.
+ */
+ public function get_description(): string {
+ return $this->description;
+ }
+
+ /**
+ * Retrieves the ability category for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return string The ability category for the ability.
+ */
+ public function get_category(): string {
+ return $this->category;
+ }
+
+ /**
+ * Retrieves the input schema for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> The input schema for the ability.
+ */
+ public function get_input_schema(): array {
+ return $this->input_schema;
+ }
+
+ /**
+ * Retrieves the output schema for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> The output schema for the ability.
+ */
+ public function get_output_schema(): array {
+ return $this->output_schema;
+ }
+
+ /**
+ * Retrieves the metadata for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> The metadata for the ability.
+ */
+ public function get_meta(): array {
+ return $this->meta;
+ }
+
+ /**
+ * Retrieves a specific metadata item for the ability.
+ *
+ * @since 6.9.0
+ *
+ * @param string $key The metadata key to retrieve.
+ * @param mixed $default_value Optional. The default value to return if the metadata item is not found. Default `null`.
+ * @return mixed The value of the metadata item, or the default value if not found.
+ */
+ public function get_meta_item( string $key, $default_value = null ) {
+ return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value;
+ }
+
+ /**
+ * Validates input data against the input schema.
+ *
+ * @since 6.9.0
+ *
+ * @param mixed $input Optional. The input data to validate. Default `null`.
+ * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails.
+ */
+ public function validate_input( $input = null ) {
+ $input_schema = $this->get_input_schema();
+ if ( empty( $input_schema ) ) {
+ if ( null === $input ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'ability_missing_input_schema',
+ sprintf(
+ /* translators: %s ability name. */
+ __( 'Ability "%s" does not define an input schema required to validate the provided input.' ),
+ $this->name
+ )
+ );
+ }
+
+ $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' );
+ if ( is_wp_error( $valid_input ) ) {
+ return new WP_Error(
+ 'ability_invalid_input',
+ sprintf(
+ /* translators: %1$s ability name, %2$s error message. */
+ __( 'Ability "%1$s" has invalid input. Reason: %2$s' ),
+ $this->name,
+ $valid_input->get_error_message()
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Invokes a callable, ensuring the input is passed through only if the input schema is defined.
+ *
+ * @since 6.9.0
+ *
+ * @param callable $callback The callable to invoke.
+ * @param mixed $input Optional. The input data for the ability. Default `null`.
+ * @return mixed The result of the callable execution.
+ */
+ protected function invoke_callback( callable $callback, $input = null ) {
+ $args = array();
+ if ( ! empty( $this->get_input_schema() ) ) {
+ $args[] = $input;
+ }
+
+ return $callback( ...$args );
+ }
+
+ /**
+ * Checks whether the ability has the necessary permissions.
+ *
+ * Please note that input is not automatically validated against the input schema.
+ * Use `validate_input()` method to validate input before calling this method if needed.
+ *
+ * @since 6.9.0
+ *
+ * @see validate_input()
+ *
+ * @param mixed $input Optional. The valid input data for permission checking. Default `null`.
+ * @return bool|WP_Error Whether the ability has the necessary permission.
+ */
+ public function check_permissions( $input = null ) {
+ return $this->invoke_callback( $this->permission_callback, $input );
+ }
+
+ /**
+ * Executes the ability callback.
+ *
+ * @since 6.9.0
+ *
+ * @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.
+ */
+ protected function do_execute( $input = null ) {
+ if ( ! is_callable( $this->execute_callback ) ) {
+ return new WP_Error(
+ 'ability_invalid_execute_callback',
+ /* translators: %s ability name. */
+ sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name )
+ );
+ }
+
+ return $this->invoke_callback( $this->execute_callback, $input );
+ }
+
+ /**
+ * Validates output data against the output schema.
+ *
+ * @since 6.9.0
+ *
+ * @param mixed $output The output data to validate.
+ * @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails.
+ */
+ protected function validate_output( $output ) {
+ $output_schema = $this->get_output_schema();
+ if ( empty( $output_schema ) ) {
+ return true;
+ }
+
+ $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' );
+ if ( is_wp_error( $valid_output ) ) {
+ return new WP_Error(
+ 'ability_invalid_output',
+ sprintf(
+ /* translators: %1$s ability name, %2$s error message. */
+ __( 'Ability "%1$s" has invalid output. Reason: %2$s' ),
+ $this->name,
+ $valid_output->get_error_message()
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Executes the ability after input validation and running a permission check.
+ * Before returning the return value, it also validates the output.
+ *
+ * @since 6.9.0
+ *
+ * @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 ) {
+ $is_valid = $this->validate_input( $input );
+ if ( is_wp_error( $is_valid ) ) {
+ return $is_valid;
+ }
+
+ $has_permissions = $this->check_permissions( $input );
+ if ( true !== $has_permissions ) {
+ if ( is_wp_error( $has_permissions ) ) {
+ // Don't leak the permission check error to someone without the correct perms.
+ _doing_it_wrong(
+ __METHOD__,
+ esc_html( $has_permissions->get_error_message() ),
+ '6.9.0'
+ );
+ }
+
+ return new WP_Error(
+ 'ability_invalid_permissions',
+ /* translators: %s ability name. */
+ sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name )
+ );
+ }
+
+ /**
+ * Fires before an ability gets executed, after input validation and permissions check.
+ *
+ * @since 6.9.0
+ *
+ * @param string $ability_name The name of the ability.
+ * @param mixed $input The input data for the ability.
+ */
+ do_action( 'wp_before_execute_ability', $this->name, $input );
+
+ $result = $this->do_execute( $input );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ $is_valid = $this->validate_output( $result );
+ if ( is_wp_error( $is_valid ) ) {
+ return $is_valid;
+ }
+
+ /**
+ * Fires immediately after an ability finished executing.
+ *
+ * @since 6.9.0
+ *
+ * @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.
+ */
+ do_action( 'wp_after_execute_ability', $this->name, $input, $result );
+
+ return $result;
+ }
+
+ /**
+ * Wakeup magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the ability object is unserialized.
+ * This is a security hardening measure to prevent unserialization of the ability.
+ */
+ public function __wakeup(): void {
+ throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+ }
+
+ /**
+ * Sleep magic method.
+ *
+ * @since 6.9.0
+ * @throws LogicException If the ability object is serialized.
+ * This is a security hardening measure to prevent serialization of the ability.
+ */
+ public function __sleep(): array {
+ throw new LogicException( __CLASS__ . ' should never be serialized' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api.php (rev 0)
+++ trunk/src/wp-includes/abilities-api.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,260 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines functions for managing abilities in WordPress.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Registers a new ability using Abilities API.
+ *
+ * Note: Should only be used on the {@see 'wp_abilities_api_init'} hook.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::register()
+ *
+ * @param string $name The name of the ability. The name must be a string containing a namespace
+ * prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
+ * alphanumeric characters, dashes and the forward slash.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability.
+ *
+ * @type string $label The human-readable label for the ability.
+ * @type string $description A detailed description of what the ability does.
+ * @type string $category The ability category slug this ability belongs to.
+ * @type callable $execute_callback A callback function to execute when the ability is invoked.
+ * Receives optional mixed input and returns mixed result or WP_Error.
+ * @type callable $permission_callback A callback function to check permissions before execution.
+ * Receives optional mixed input and returns bool or WP_Error.
+ * @type array<string, mixed> $input_schema Optional. JSON Schema definition for the ability's input.
+ * @type array<string, mixed> $output_schema Optional. JSON Schema definition for the ability's output.
+ * @type array<string, mixed> $meta {
+ * Optional. Additional metadata for the ability.
+ *
+ * @type array<string, null|bool> $annotations Optional. Annotation metadata for the ability.
+ * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ * }
+ * @type string $ability_class Optional. Custom class to instantiate instead of WP_Ability.
+ * }
+ * @return WP_Ability|null An instance of registered ability on success, null on failure.
+ */
+function wp_register_ability( string $name, array $args ): ?WP_Ability {
+ if ( ! did_action( 'wp_abilities_api_init' ) ) {
+ _doing_it_wrong(
+ __FUNCTION__,
+ sprintf(
+ /* translators: 1: abilities_api_init, 2: string value of the ability name. */
+ esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ),
+ '<code>abilities_api_init</code>',
+ '<code>' . esc_html( $name ) . '</code>'
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $registry = WP_Abilities_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->register( $name, $args );
+}
+
+/**
+ * Unregisters an ability from the Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::unregister()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return WP_Ability|null The unregistered ability instance on success, null on failure.
+ */
+function wp_unregister_ability( string $name ): ?WP_Ability {
+ $registry = WP_Abilities_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->unregister( $name );
+}
+
+/**
+ * Checks if an ability is registered.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::is_registered()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return bool True if the ability is registered, false otherwise.
+ */
+function wp_has_ability( string $name ): bool {
+ $registry = WP_Abilities_Registry::get_instance();
+ if ( null === $registry ) {
+ return false;
+ }
+
+ return $registry->is_registered( $name );
+}
+
+/**
+ * Retrieves a registered ability using Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::get_registered()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return WP_Ability|null The registered ability instance, or null if it is not registered.
+ */
+function wp_get_ability( string $name ): ?WP_Ability {
+ $registry = WP_Abilities_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->get_registered( $name );
+}
+
+/**
+ * Retrieves all registered abilities using Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::get_all_registered()
+ *
+ * @return WP_Ability[] The array of registered abilities.
+ */
+function wp_get_abilities(): array {
+ $registry = WP_Abilities_Registry::get_instance();
+ if ( null === $registry ) {
+ return array();
+ }
+
+ return $registry->get_all_registered();
+}
+
+/**
+ * Registers a new ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::register()
+ *
+ * @param string $slug The unique slug for the ability category. Must contain only lowercase
+ * alphanumeric characters and dashes.
+ * @param array<string, mixed> $args {
+ * An associative array of arguments for the ability category.
+ *
+ * @type string $label The human-readable label for the ability category.
+ * @type string $description A description of the ability category.
+ * @type array<string, mixed> $meta Optional. Additional metadata for the ability category.
+ * }
+ * @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
+ */
+function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category {
+ if ( ! did_action( 'wp_abilities_api_categories_init' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: abilities_api_categories_init, 2: ability category slug. */
+ __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ),
+ '<code>wp_abilities_api_categories_init</code>',
+ '<code>' . esc_html( $slug ) . '</code>'
+ ),
+ '6.9.0'
+ );
+ return null;
+ }
+
+ $registry = WP_Ability_Categories_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->register( $slug, $args );
+}
+
+/**
+ * Unregisters an ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::unregister()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
+ */
+function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category {
+ $registry = WP_Ability_Categories_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->unregister( $slug );
+}
+
+/**
+ * Checks if an ability category is registered.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::is_registered()
+ *
+ * @param string $slug The slug of the ability category.
+ * @return bool True if the ability category is registered, false otherwise.
+ */
+function wp_has_ability_category( string $slug ): bool {
+ $registry = WP_Ability_Categories_Registry::get_instance();
+ if ( null === $registry ) {
+ return false;
+ }
+
+ return $registry->is_registered( $slug );
+}
+
+/**
+ * Retrieves a registered ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::get_registered()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
+ */
+function wp_get_ability_category( string $slug ): ?WP_Ability_Category {
+ $registry = WP_Ability_Categories_Registry::get_instance();
+ if ( null === $registry ) {
+ return null;
+ }
+
+ return $registry->get_registered( $slug );
+}
+
+/**
+ * Retrieves all registered ability categories.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::get_all_registered()
+ *
+ * @return WP_Ability_Category[] The array of registered ability categories.
+ */
+function wp_get_ability_categories(): array {
+ $registry = WP_Ability_Categories_Registry::get_instance();
+ if ( null === $registry ) {
+ return array();
+ }
+
+ return $registry->get_all_registered();
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1listcontrollerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php (rev 0)
+++ trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,343 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * REST API list controller for Abilities API.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Core controller used to access abilities via the REST API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller {
+
+ /**
+ * REST API namespace.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $namespace = 'wp-abilities/v1';
+
+ /**
+ * REST API base route.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $rest_base = 'abilities';
+
+ /**
+ * Registers the routes for abilities.
+ *
+ * @since 6.9.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => $this->get_collection_params(),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+)',
+ array(
+ 'args' => array(
+ 'name' => array(
+ 'description' => __( 'Unique identifier for the ability.' ),
+ 'type' => 'string',
+ 'pattern' => '^[a-zA-Z0-9\-\/]+$',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Retrieves all abilities.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response Response object on success.
+ */
+ public function get_items( $request ) {
+ $abilities = array_filter(
+ wp_get_abilities(),
+ static function ( $ability ) {
+ return $ability->get_meta_item( 'show_in_rest' );
+ }
+ );
+
+ // Filter by ability category if specified.
+ $category = $request['category'];
+ if ( ! empty( $category ) ) {
+ $abilities = array_filter(
+ $abilities,
+ static function ( $ability ) use ( $category ) {
+ return $ability->get_category() === $category;
+ }
+ );
+ // Reset array keys after filtering.
+ $abilities = array_values( $abilities );
+ }
+
+ $page = $request['page'];
+ $per_page = $request['per_page'];
+ $offset = ( $page - 1 ) * $per_page;
+
+ $total_abilities = count( $abilities );
+ $max_pages = ceil( $total_abilities / $per_page );
+
+ if ( $request->get_method() === 'HEAD' ) {
+ $response = new WP_REST_Response( array() );
+ } else {
+ $abilities = array_slice( $abilities, $offset, $per_page );
+
+ $data = array();
+ foreach ( $abilities as $ability ) {
+ $item = $this->prepare_item_for_response( $ability, $request );
+ $data[] = $this->prepare_response_for_collection( $item );
+ }
+
+ $response = rest_ensure_response( $data );
+ }
+
+ $response->header( 'X-WP-Total', (string) $total_abilities );
+ $response->header( 'X-WP-TotalPages', (string) $max_pages );
+
+ $query_params = $request->get_query_params();
+ $base = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
+
+ if ( $page > 1 ) {
+ $prev_page = $page - 1;
+ $prev_link = add_query_arg( 'page', $prev_page, $base );
+ $response->link_header( 'prev', $prev_link );
+ }
+
+ if ( $page < $max_pages ) {
+ $next_page = $page + 1;
+ $next_link = add_query_arg( 'page', $next_page, $base );
+ $response->link_header( 'next', $next_link );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Retrieves a specific ability.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_item( $request ) {
+ $ability = wp_get_ability( $request['name'] );
+ if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
+ return new WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $data = $this->prepare_item_for_response( $ability, $request );
+ return rest_ensure_response( $data );
+ }
+
+ /**
+ * Checks if a given request has access to read ability items.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool True if the request has read access.
+ */
+ public function get_items_permissions_check( $request ) {
+ return current_user_can( 'read' );
+ }
+
+ /**
+ * Checks if a given request has access to read an ability item.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return bool True if the request has read access.
+ */
+ public function get_item_permissions_check( $request ) {
+ return current_user_can( 'read' );
+ }
+
+ /**
+ * Prepares an ability for response.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_Ability $ability The ability object.
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response Response object.
+ */
+ public function prepare_item_for_response( $ability, $request ) {
+ $data = array(
+ 'name' => $ability->get_name(),
+ 'label' => $ability->get_label(),
+ 'description' => $ability->get_description(),
+ 'category' => $ability->get_category(),
+ 'input_schema' => $ability->get_input_schema(),
+ 'output_schema' => $ability->get_output_schema(),
+ 'meta' => $ability->get_meta(),
+ );
+
+ $context = $request['context'] ?? 'view';
+ $data = $this->add_additional_fields_to_object( $data, $request );
+ $data = $this->filter_response_by_context( $data, $context );
+
+ $response = rest_ensure_response( $data );
+
+ $fields = $this->get_fields_for_response( $request );
+ if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+ $links = array(
+ 'self' => array(
+ 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $ability->get_name() ) ),
+ ),
+ 'collection' => array(
+ 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+ ),
+ );
+
+ $links['wp:action-run'] = array(
+ 'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ),
+ );
+
+ $response->add_links( $links );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Retrieves the ability's schema, conforming to JSON Schema.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> Item schema data.
+ */
+ public function get_item_schema(): array {
+ $schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'ability',
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array(
+ 'description' => __( 'Unique identifier for the ability.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'label' => array(
+ 'description' => __( 'Display label for the ability.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'description' => array(
+ 'description' => __( 'Description of the ability.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'category' => array(
+ 'description' => __( 'Ability category this ability belongs to.' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'readonly' => true,
+ ),
+ 'input_schema' => array(
+ 'description' => __( 'JSON Schema for the ability input.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'output_schema' => array(
+ 'description' => __( 'JSON Schema for the ability output.' ),
+ 'type' => 'object',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'meta' => array(
+ 'description' => __( 'Meta information about the ability.' ),
+ 'type' => 'object',
+ 'properties' => array(
+ 'annotations' => array(
+ 'description' => __( 'Annotations for the ability.' ),
+ 'type' => array( 'boolean', 'null' ),
+ 'default' => null,
+ ),
+ ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $schema );
+ }
+
+ /**
+ * Retrieves the query params for collections.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> Collection parameters.
+ */
+ public function get_collection_params(): array {
+ return array(
+ 'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+ 'page' => array(
+ 'description' => __( 'Current page of the collection.' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ ),
+ 'per_page' => array(
+ 'description' => __( 'Maximum number of items to be returned in result set.' ),
+ 'type' => 'integer',
+ 'default' => 50,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ ),
+ 'category' => array(
+ 'description' => __( 'Limit results to abilities in specific ability category.' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1runcontrollerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php (rev 0)
+++ trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,243 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * REST API run controller for Abilities API.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Core controller used to execute abilities via the REST API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
+
+ /**
+ * REST API namespace.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $namespace = 'wp-abilities/v1';
+
+ /**
+ * REST API base route.
+ *
+ * @since 6.9.0
+ * @var string
+ */
+ protected $rest_base = 'abilities';
+
+ /**
+ * Registers the routes for ability execution.
+ *
+ * @since 6.9.0
+ *
+ * @see register_rest_route()
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+?)/run',
+ array(
+ 'args' => array(
+ 'name' => array(
+ 'description' => __( 'Unique identifier for the ability.' ),
+ 'type' => 'string',
+ 'pattern' => '^[a-zA-Z0-9\-\/]+$',
+ ),
+ ),
+
+ // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities
+ // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress
+ // load order - routes are registered early, before plugins have registered their abilities.
+ // This approach works but could be improved with lazy route registration or a different
+ // architecture that allows type-specific routes after abilities are registered.
+ // This was the same issue that we ended up seeing with the Feature API.
+ array(
+ 'methods' => WP_REST_Server::ALLMETHODS,
+ 'callback' => array( $this, 'execute_ability' ),
+ 'permission_callback' => array( $this, 'check_ability_permissions' ),
+ 'args' => $this->get_run_args(),
+ ),
+ 'schema' => array( $this, 'get_run_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Executes an ability.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function execute_ability( $request ) {
+ $ability = wp_get_ability( $request['name'] );
+ if ( ! $ability ) {
+ return new WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $input = $this->get_input_from_request( $request );
+ $result = $ability->execute( $input );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return rest_ensure_response( $result );
+ }
+
+ /**
+ * Validates if the HTTP method matches the expected method for the ability based on its annotations.
+ *
+ * @since 6.9.0
+ *
+ * @param string $request_method The HTTP method of the request.
+ * @param array<string, (null|bool)> $annotations The ability annotations.
+ * @return true|WP_Error True on success, or WP_Error object on failure.
+ */
+ public function validate_request_method( string $request_method, array $annotations ) {
+ $expected_method = 'POST';
+ if ( ! empty( $annotations['readonly'] ) ) {
+ $expected_method = 'GET';
+ } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) {
+ $expected_method = 'DELETE';
+ }
+
+ if ( $expected_method === $request_method ) {
+ return true;
+ }
+
+ $error_message = __( 'Abilities that perform updates require POST method.' );
+ if ( 'GET' === $expected_method ) {
+ $error_message = __( 'Read-only abilities require GET method.' );
+ } elseif ( 'DELETE' === $expected_method ) {
+ $error_message = __( 'Abilities that perform destructive actions require DELETE method.' );
+ }
+ return new WP_Error(
+ 'rest_ability_invalid_method',
+ $error_message,
+ array( 'status' => 405 )
+ );
+ }
+
+ /**
+ * Checks if a given request has permission to execute a specific ability.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise.
+ */
+ public function check_ability_permissions( $request ) {
+ $ability = wp_get_ability( $request['name'] );
+ if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
+ return new WP_Error(
+ 'rest_ability_not_found',
+ __( 'Ability not found.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $is_valid = $this->validate_request_method(
+ $request->get_method(),
+ $ability->get_meta_item( 'annotations' )
+ );
+ if ( is_wp_error( $is_valid ) ) {
+ return $is_valid;
+ }
+
+ $input = $this->get_input_from_request( $request );
+ $is_valid = $ability->validate_input( $input );
+ if ( is_wp_error( $is_valid ) ) {
+ $is_valid->add_data( array( 'status' => 400 ) );
+ return $is_valid;
+ }
+
+ $result = $ability->check_permissions( $input );
+ if ( is_wp_error( $result ) ) {
+ $result->add_data( array( 'status' => rest_authorization_required_code() ) );
+ return $result;
+ }
+ if ( ! $result ) {
+ return new WP_Error(
+ 'rest_ability_cannot_execute',
+ __( 'Sorry, you are not allowed to execute this ability.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Extracts input parameters from the request.
+ *
+ * @since 6.9.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return mixed|null The input parameters.
+ */
+ private function get_input_from_request( $request ) {
+ if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) {
+ // For GET and DELETE requests, look for 'input' query parameter.
+ $query_params = $request->get_query_params();
+ return $query_params['input'] ?? null;
+ }
+
+ // For POST requests, look for 'input' in JSON body.
+ $json_params = $request->get_json_params();
+ return $json_params['input'] ?? null;
+ }
+
+ /**
+ * Retrieves the arguments for ability execution endpoint.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> Arguments for the run endpoint.
+ */
+ public function get_run_args(): array {
+ return array(
+ 'input' => array(
+ 'description' => __( 'Input parameters for the ability execution.' ),
+ 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
+ 'default' => null,
+ ),
+ );
+ }
+
+ /**
+ * Retrieves the schema for ability execution endpoint.
+ *
+ * @since 6.9.0
+ *
+ * @return array<string, mixed> Schema for the run endpoint.
+ */
+ public function get_run_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'ability-execution',
+ 'type' => 'object',
+ 'properties' => array(
+ 'result' => array(
+ 'description' => __( 'The result of the ability execution.' ),
+ 'type' => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/rest-api.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api.php 2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/src/wp-includes/rest-api.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -483,6 +483,12 @@
</span><span class="cx" style="display: block; padding: 0 10px"> // Font Collections.
</span><span class="cx" style="display: block; padding: 0 10px"> $font_collections_controller = new WP_REST_Font_Collections_Controller();
</span><span class="cx" style="display: block; padding: 0 10px"> $font_collections_controller->register_routes();
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+ // Abilities.
+ $abilities_run_controller = new WP_REST_Abilities_V1_Run_Controller();
+ $abilities_run_controller->register_routes();
+ $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller();
+ $abilities_list_controller->register_routes();
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> /**
</span></span></pre></div>
<a id="trunksrcwpsettingsphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-settings.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-settings.php 2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/src/wp-settings.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -285,6 +285,11 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/nav-menu.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/admin-bar.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/class-wp-application-passwords.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
+require ABSPATH . WPINC . '/abilities-api.php';
</ins><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -331,6 +336,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php';
+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php';
</ins><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php';
</span></span></pre></div>
<a id="trunktestsphpunittestsabilitiesapiwpAbilitiesRegistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,651 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the abilities registry functionality.
+ *
+ * @covers WP_Abilities_Registry
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase {
+
+ public static $test_ability_name = 'test/add-numbers';
+ public static $test_ability_args = array();
+
+ /**
+ * Mock abilities registry.
+ *
+ * @var WP_Abilities_Registry
+ */
+ private $registry = null;
+
+ /**
+ * Set up each test method.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $this->registry = new WP_Abilities_Registry();
+
+ remove_all_filters( 'wp_register_ability_args' );
+
+ // Fire the init hook to allow test ability category registration.
+ do_action( 'wp_abilities_api_categories_init' );
+ wp_register_ability_category(
+ 'math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations and calculations.',
+ )
+ );
+
+ self::$test_ability_args = array(
+ 'label' => 'Add numbers',
+ 'description' => 'Calculates the result of adding two numbers.',
+ 'category' => 'math',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'a' => array(
+ 'type' => 'number',
+ 'description' => 'First number.',
+ 'required' => true,
+ ),
+ 'b' => array(
+ 'type' => 'number',
+ 'description' => 'Second number.',
+ 'required' => true,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => array(
+ 'type' => 'number',
+ 'description' => 'The result of adding the two numbers.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function ( array $input ): int {
+ return $input['a'] + $input['b'];
+ },
+ 'permission_callback' => static function (): bool {
+ return true;
+ },
+ 'meta' => array(
+ 'foo' => 'bar',
+ ),
+ );
+ }
+
+ /**
+ * Tear down each test method.
+ */
+ public function tear_down(): void {
+ $this->registry = null;
+
+ remove_all_filters( 'wp_register_ability_args' );
+
+ // Clean up registered test ability category.
+ wp_unregister_ability_category( 'math' );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Should reject ability name without a namespace.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_name_without_namespace() {
+ $result = $this->registry->register( 'without-namespace', self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability name with invalid characters.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_characters_in_name() {
+ $result = $this->registry->register( 'still/_doing_it_wrong', array() );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability name with uppercase characters.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_uppercase_characters_in_name() {
+ $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration without a label.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_missing_label() {
+ // Remove the label from the args.
+ unset( self::$test_ability_args['label'] );
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration with invalid label type.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_label_type() {
+ self::$test_ability_args['label'] = false;
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration without a description.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_missing_description() {
+ // Remove the description from the args.
+ unset( self::$test_ability_args['description'] );
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration with invalid description type.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_description_type() {
+ self::$test_ability_args['description'] = false;
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests registering an ability with non-existent category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_ability_nonexistent_category(): void {
+ $args = array_merge(
+ self::$test_ability_args,
+ array( 'category' => 'nonexistent' )
+ );
+
+ $result = $this->registry->register( self::$test_ability_name, $args );
+
+ $this->assertNull( $result, 'Should return null when category does not exist.' );
+ }
+
+ /**
+ * Should reject ability registration without an execute callback.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_missing_execute_callback() {
+ // Remove the execute_callback from the args.
+ unset( self::$test_ability_args['execute_callback'] );
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration if the execute callback is not a callable.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_incorrect_execute_callback_type() {
+ self::$test_ability_args['execute_callback'] = 'not-a-callback';
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration without an execute callback.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_missing_permission_callback() {
+ // Remove the permission_callback from the args.
+ unset( self::$test_ability_args['permission_callback'] );
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration if the permission callback is not a callable.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_incorrect_permission_callback_type() {
+ self::$test_ability_args['permission_callback'] = 'not-a-callback';
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration if the input schema is not an array.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_incorrect_input_schema_type() {
+ self::$test_ability_args['input_schema'] = 'not-an-array';
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration if the output schema is not an array.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_incorrect_output_schema_type() {
+ self::$test_ability_args['output_schema'] = 'not-an-array';
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+
+ /**
+ * Should reject ability registration with invalid `annotations` type.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_annotations_type() {
+ self::$test_ability_args['meta']['annotations'] = false;
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration with invalid meta type.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_meta_type() {
+ self::$test_ability_args['meta'] = false;
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject ability registration with invalid show in REST type.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Ability::prepare_properties
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_invalid_show_in_rest_type() {
+ self::$test_ability_args['meta']['show_in_rest'] = 5;
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should reject registration for already registered ability.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_incorrect_already_registered_ability() {
+ $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should successfully register a new ability.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ */
+ public function test_register_new_ability() {
+ $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ $this->assertEquals(
+ new WP_Ability( self::$test_ability_name, self::$test_ability_args ),
+ $result
+ );
+ }
+
+ /**
+ * Should return false for ability that's not registered.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::is_registered
+ */
+ public function test_is_registered_for_unknown_ability() {
+ $result = $this->registry->is_registered( 'test/unknown' );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Should return true if ability is registered.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Abilities_Registry::is_registered
+ */
+ public function test_is_registered_for_known_ability() {
+ $this->registry->register( 'test/one', self::$test_ability_args );
+ $this->registry->register( 'test/two', self::$test_ability_args );
+ $this->registry->register( 'test/three', self::$test_ability_args );
+
+ $result = $this->registry->is_registered( 'test/one' );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Should not find ability that's not registered.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::get_registered
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+ */
+ public function test_get_registered_rejects_unknown_ability_name() {
+ $ability = $this->registry->get_registered( 'test/unknown' );
+ $this->assertNull( $ability );
+ }
+
+ /**
+ * Should find registered ability by name.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Abilities_Registry::get_registered
+ */
+ public function test_get_registered_for_known_ability() {
+ $this->registry->register( 'test/one', self::$test_ability_args );
+ $this->registry->register( 'test/two', self::$test_ability_args );
+ $this->registry->register( 'test/three', self::$test_ability_args );
+
+ $result = $this->registry->get_registered( 'test/two' );
+ $this->assertEquals( 'test/two', $result->get_name() );
+ }
+
+ /**
+ * Unregistering should fail if an ability is not registered.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::unregister
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::unregister
+ */
+ public function test_unregister_not_registered_ability() {
+ $result = $this->registry->unregister( 'test/unregistered' );
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Should unregister ability by name.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Abilities_Registry::unregister
+ */
+ public function test_unregister_for_known_ability() {
+ $this->registry->register( 'test/one', self::$test_ability_args );
+ $this->registry->register( 'test/two', self::$test_ability_args );
+ $this->registry->register( 'test/three', self::$test_ability_args );
+
+ $result = $this->registry->unregister( 'test/three' );
+ $this->assertEquals( 'test/three', $result->get_name() );
+
+ $this->assertFalse( $this->registry->is_registered( 'test/three' ) );
+ }
+
+ /**
+ * Should retrieve all registered abilities.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Abilities_Registry::register
+ * @covers WP_Abilities_Registry::get_all_registered
+ */
+ public function test_get_all_registered() {
+ $ability_one_name = 'test/one';
+ $this->registry->register( $ability_one_name, self::$test_ability_args );
+
+ $ability_two_name = 'test/two';
+ $this->registry->register( $ability_two_name, self::$test_ability_args );
+
+ $ability_three_name = 'test/three';
+ $this->registry->register( $ability_three_name, self::$test_ability_args );
+
+ $result = $this->registry->get_all_registered();
+ $this->assertCount( 3, $result );
+ $this->assertSame( $ability_one_name, $result[ $ability_one_name ]->get_name() );
+ $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() );
+ $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() );
+ }
+
+ /**
+ * Test register_ability_args filter modifies the args before ability instantiation.
+ *
+ * @ticket 64098
+ */
+ public function test_register_ability_args_filter_modifies_args() {
+ $was_filter_callback_fired = false;
+
+ // Define the filter.
+ add_filter(
+ 'wp_register_ability_args',
+ static function ( $args ) use ( &$was_filter_callback_fired ) {
+ $args['label'] = 'Modified label';
+ $original_execute_callback = $args['execute_callback'];
+ $args['execute_callback'] = static function ( array $input ) use ( &$was_filter_callback_fired, $original_execute_callback ) {
+ $was_filter_callback_fired = true;
+ return $original_execute_callback( $input );
+ };
+
+ return $args;
+ },
+ 10
+ );
+
+ // Register the ability.
+ $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ // Check the label was modified by the filter.
+ $this->assertSame( 'Modified label', $ability->get_label() );
+
+ // Call the execute callback.
+ $result = $ability->execute(
+ array(
+ 'a' => 1,
+ 'b' => 2,
+ )
+ );
+
+ $this->assertTrue( $was_filter_callback_fired, 'The execute callback defined in the filter was not fired.' );
+ $this->assertSame( 3, $result, 'The original execute callback did not return the expected result.' );
+ }
+
+ /**
+ * Test register_ability_args filter can block ability registration by returning invalid args.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_ability_args_filter_blocks_registration() {
+ // Define the filter.
+ add_filter(
+ 'wp_register_ability_args',
+ static function ( $args ) {
+ // Remove the label to make the args invalid.
+ unset( $args['label'] );
+ return $args;
+ },
+ 10
+ );
+
+ // Register the ability.
+ $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ // Check the ability was not registered.
+ $this->assertNull( $ability, 'The ability was registered even though the args were made invalid by the filter.' );
+ }
+
+ /**
+ * Test register_ability_args filter can block an invalid ability class from being used.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_ability_args_filter_blocks_invalid_ability_class() {
+ // Define the filter.
+ add_filter(
+ 'wp_register_ability_args',
+ static function ( $args ) {
+ // Set an invalid ability class.
+ $args['ability_class'] = 'NonExistentClass';
+ return $args;
+ },
+ 10
+ );
+ // Register the ability.
+ $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+ // Check the ability was not registered.
+ $this->assertNull( $ability, 'The ability was registered even though the ability class was made invalid by the filter.' );
+ }
+
+ /**
+ * Tests register_ability_args filter is only applied to the specific ability being registered.
+ *
+ * @ticket 64098
+ */
+ public function test_register_ability_args_filter_only_applies_to_specific_ability() {
+ add_filter(
+ 'wp_register_ability_args',
+ static function ( $args, $name ) {
+ if ( self::$test_ability_name !== $name ) {
+ // Do not modify args for other abilities.
+ return $args;
+ }
+
+ $args['label'] = 'Modified label for specific ability';
+ return $args;
+ },
+ 10,
+ 2
+ );
+
+ // Register the first ability, which the filter should modify.
+ $filtered_ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+ $this->assertSame( 'Modified label for specific ability', $filtered_ability->get_label() );
+
+ $unfiltered_ability = $this->registry->register( 'test/another-ability', self::$test_ability_args );
+ $this->assertNotSame( $filtered_ability->get_label(), $unfiltered_ability->get_label(), 'The filter incorrectly modified the args for an ability it should not have.' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpAbilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbility.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbility.php (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbility.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,794 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the abilities registry functionality.
+ *
+ * @covers WP_Ability
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbility extends WP_UnitTestCase {
+
+ public static $test_ability_name = 'test/calculator';
+ public static $test_ability_properties = array();
+
+ /**
+ * Set up each test method.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ // Fire the init hook to allow test ability category registration.
+ do_action( 'wp_abilities_api_categories_init' );
+ wp_register_ability_category(
+ 'math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations and calculations.',
+ )
+ );
+
+ self::$test_ability_properties = array(
+ 'label' => 'Calculator',
+ 'description' => 'Calculates the result of math operations.',
+ 'category' => 'math',
+ 'output_schema' => array(
+ 'type' => 'number',
+ 'description' => 'The result of performing a math operation.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function (): int {
+ return 0;
+ },
+ 'permission_callback' => static function (): bool {
+ return true;
+ },
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ // Clean up registered test ability category.
+ wp_unregister_ability_category( 'math' );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Direct instantiation of WP_Ability with invalid properties should throw an exception.
+ *
+ * @ticket 64098
+ *
+ * @covers WP_Ability::__construct
+ * @covers WP_Ability::prepare_properties
+ */
+ public function test_wp_ability_invalid_properties_throws_exception() {
+ $this->expectException( InvalidArgumentException::class );
+ new WP_Ability(
+ 'test/invalid',
+ array(
+ 'label' => '',
+ 'description' => '',
+ 'execute_callback' => null,
+ )
+ );
+ }
+
+ /*
+ * Tests that getting non-existing metadata item returns default value.
+ *
+ * @ticket 64098
+ */
+ public function test_meta_get_non_existing_item_returns_default() {
+ $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+ $this->assertNull(
+ $ability->get_meta_item( 'non_existing' ),
+ 'Non-existing metadata item should return null.'
+ );
+ }
+
+ /**
+ * Tests that getting non-existing metadata item with custom default returns that default.
+ *
+ * @ticket 64098
+ */
+ public function test_meta_get_non_existing_item_with_custom_default() {
+ $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+ $this->assertSame(
+ 'default_value',
+ $ability->get_meta_item( 'non_existing', 'default_value' ),
+ 'Non-existing metadata item should return custom default value.'
+ );
+ }
+
+ /**
+ * Tests getting all annotations when selective overrides are applied.
+ *
+ * @ticket 64098
+ */
+ public function test_get_merged_annotations_from_meta() {
+ $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+ $this->assertSame(
+ array_merge(
+ self::$test_ability_properties['meta']['annotations'],
+ array(
+ 'idempotent' => null,
+ )
+ ),
+ $ability->get_meta_item( 'annotations' )
+ );
+ }
+
+ /**
+ * Tests getting default annotations when not provided.
+ *
+ * @ticket 64098
+ */
+ public function test_get_default_annotations_from_meta() {
+ $args = self::$test_ability_properties;
+ unset( $args['meta']['annotations'] );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertSame(
+ array(
+ 'readonly' => null,
+ 'destructive' => null,
+ 'idempotent' => null,
+ ),
+ $ability->get_meta_item( 'annotations' )
+ );
+ }
+
+ /**
+ * Tests getting all annotations when values overridden.
+ *
+ * @ticket 64098
+ */
+ public function test_get_overridden_annotations_from_meta() {
+ $annotations = array(
+ 'readonly' => true,
+ 'destructive' => false,
+ 'idempotent' => false,
+ );
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'meta' => array(
+ 'annotations' => $annotations,
+ ),
+ )
+ );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertSame( $annotations, $ability->get_meta_item( 'annotations' ) );
+ }
+
+ /**
+ * Tests that invalid `annotations` value throws an exception.
+ *
+ * @ticket 64098
+ */
+ public function test_annotations_from_meta_throws_exception() {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'meta' => array(
+ 'annotations' => 5,
+ ),
+ )
+ );
+
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'The ability meta should provide a valid `annotations` array.' );
+
+ new WP_Ability( self::$test_ability_name, $args );
+ }
+
+ /**
+ * Tests that `show_in_rest` metadata defaults to false when not provided.
+ *
+ * @ticket 64098
+ */
+ public function test_meta_show_in_rest_defaults_to_false() {
+ $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+ $this->assertFalse(
+ $ability->get_meta_item( 'show_in_rest' ),
+ '`show_in_rest` metadata should default to false.'
+ );
+ }
+
+ /**
+ * Tests that `show_in_rest` metadata can be set to true.
+ *
+ * @ticket 64098
+ */
+ public function test_meta_show_in_rest_can_be_set_to_true() {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertTrue(
+ $ability->get_meta_item( 'show_in_rest' ),
+ '`show_in_rest` metadata should be true.'
+ );
+ }
+
+ /**
+ * Tests that `show_in_rest` can be set to false.
+ *
+ * @ticket 64098
+ */
+ public function test_show_in_rest_can_be_set_to_false() {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'meta' => array(
+ 'show_in_rest' => false,
+ ),
+ )
+ );
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertFalse(
+ $ability->get_meta_item( 'show_in_rest' ),
+ '`show_in_rest` metadata should be false.'
+ );
+ }
+
+ /**
+ * Tests that invalid `show_in_rest` value throws an exception.
+ *
+ * @ticket 64098
+ */
+ public function test_show_in_rest_throws_exception() {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'meta' => array(
+ 'show_in_rest' => 5,
+ ),
+ )
+ );
+
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage( 'The ability meta should provide a valid `show_in_rest` boolean.' );
+
+ new WP_Ability( self::$test_ability_name, $args );
+ }
+
+ /**
+ * Data provider for testing the execution of the ability.
+ *
+ * @return array<string, array{0: array, 1: callable, 2: mixed, 3: mixed}> Data sets with different configurations.
+ */
+ public function data_execute_input() {
+ return array(
+ 'null input' => array(
+ array(
+ 'type' => array( 'null', 'integer' ),
+ 'description' => 'The null or integer to convert to integer.',
+ 'required' => true,
+ ),
+ static function ( $input ): int {
+ return null === $input ? 0 : (int) $input;
+ },
+ null,
+ 0,
+ ),
+ 'boolean input' => array(
+ array(
+ 'type' => 'boolean',
+ 'description' => 'The boolean to convert to integer.',
+ 'required' => true,
+ ),
+ static function ( bool $input ): int {
+ return $input ? 1 : 0;
+ },
+ true,
+ 1,
+ ),
+ 'integer input' => array(
+ array(
+ 'type' => 'integer',
+ 'description' => 'The integer to add 5 to.',
+ 'required' => true,
+ ),
+ static function ( int $input ): int {
+ return 5 + $input;
+ },
+ 2,
+ 7,
+ ),
+ 'number input' => array(
+ array(
+ 'type' => 'number',
+ 'description' => 'The floating number to round.',
+ 'required' => true,
+ ),
+ static function ( float $input ): int {
+ return (int) round( $input );
+ },
+ 2.7,
+ 3,
+ ),
+ 'string input' => array(
+ array(
+ 'type' => 'string',
+ 'description' => 'The string to measure the length of.',
+ 'required' => true,
+ ),
+ static function ( string $input ): int {
+ return strlen( $input );
+ },
+ 'Hello world!',
+ 12,
+ ),
+ 'object input' => array(
+ array(
+ 'type' => 'object',
+ 'description' => 'An object containing two numbers to add.',
+ 'properties' => array(
+ 'a' => array(
+ 'type' => 'integer',
+ 'description' => 'First number.',
+ 'required' => true,
+ ),
+ 'b' => array(
+ 'type' => 'integer',
+ 'description' => 'Second number.',
+ 'required' => true,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ static function ( array $input ): int {
+ return $input['a'] + $input['b'];
+ },
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ ),
+ 5,
+ ),
+ 'array input' => array(
+ array(
+ 'type' => 'array',
+ 'description' => 'An array containing two numbers to add.',
+ 'required' => true,
+ 'minItems' => 2,
+ 'maxItems' => 2,
+ 'items' => array(
+ 'type' => 'integer',
+ ),
+ ),
+ static function ( array $input ): int {
+ return $input[0] + $input[1];
+ },
+ array( 2, 3 ),
+ 5,
+ ),
+ );
+ }
+
+ /**
+ * Tests the execution of the ability.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_execute_input
+ *
+ * @param array $input_schema The input schema for the ability.
+ * @param callable $execute_callback The execute callback for the ability.
+ * @param mixed $input The input to pass to the execute method.
+ * @param mixed $result The expected result from the execute method.
+ */
+ public function test_execute_input( $input_schema, $execute_callback, $input, $result ) {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'input_schema' => $input_schema,
+ 'execute_callback' => $execute_callback,
+ )
+ );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertSame( $result, $ability->execute( $input ) );
+ }
+
+ /**
+ * A static method to be used as a callback in tests.
+ *
+ * @param string $input An input string.
+ * @return int The length of the input string.
+ */
+ public static function my_static_execute_callback( string $input ): int {
+ return strlen( $input );
+ }
+
+ /**
+ * An instance method to be used as a callback in tests.
+ *
+ * @param string $input An input string.
+ * @return int The length of the input string.
+ */
+ public function my_instance_execute_callback( string $input ): int {
+ return strlen( $input );
+ }
+
+ /**
+ * Data provider for testing different types of execute callbacks.
+ *
+ * @return array<string, array{0: callable}> Data sets with different execute callbacks.
+ */
+ public function data_execute_callback() {
+ return array(
+ 'function name string' => array(
+ 'strlen',
+ ),
+ 'closure' => array(
+ static function ( string $input ): int {
+ return strlen( $input );
+ },
+ ),
+ 'static class method string' => array(
+ 'Tests_Abilities_API_WpAbility::my_static_execute_callback',
+ ),
+ 'static class method array' => array(
+ array( 'Tests_Abilities_API_WpAbility', 'my_static_execute_callback' ),
+ ),
+ 'object method' => array(
+ array( $this, 'my_instance_execute_callback' ),
+ ),
+ );
+ }
+
+ /**
+ * Tests the execution of the ability with different types of callbacks.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_execute_callback
+ *
+ * @param callable $execute_callback The execute callback to test.
+ */
+ public function test_execute_with_different_callbacks( $execute_callback ) {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'input_schema' => array(
+ 'type' => 'string',
+ 'description' => 'Test input string.',
+ 'required' => true,
+ ),
+ 'execute_callback' => $execute_callback,
+ )
+ );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertSame( 6, $ability->execute( 'hello!' ) );
+ }
+
+ /**
+ * Tests the execution of the ability with no input.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_no_input() {
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'execute_callback' => static function (): int {
+ return 42;
+ },
+ )
+ );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+
+ $this->assertSame( 42, $ability->execute() );
+ }
+
+ /**
+ * Tests that before_execute_ability action is fired with correct parameters.
+ *
+ * @ticket 64098
+ */
+ public function test_before_execute_ability_action() {
+ $action_ability_name = null;
+ $action_input = null;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'input_schema' => array(
+ 'type' => 'integer',
+ 'description' => 'Test input parameter.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function ( int $input ): int {
+ return $input * 2;
+ },
+ )
+ );
+
+ $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) {
+ $action_ability_name = $ability_name;
+ $action_input = $input;
+ };
+
+ add_action( 'wp_before_execute_ability', $callback, 10, 2 );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute( 5 );
+
+ remove_action( 'wp_before_execute_ability', $callback );
+
+ $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( 10, $result, 'Ability should execute correctly' );
+ }
+
+ /**
+ * Tests that before_execute_ability action is fired with null input when no input schema is defined.
+ *
+ * @ticket 64098
+ */
+ public function test_before_execute_ability_action_no_input() {
+ $action_ability_name = null;
+ $action_input = null;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'execute_callback' => static function (): int {
+ return 42;
+ },
+ )
+ );
+
+ $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) {
+ $action_ability_name = $ability_name;
+ $action_input = $input;
+ };
+
+ add_action( 'wp_before_execute_ability', $callback, 10, 2 );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute();
+
+ remove_action( 'wp_before_execute_ability', $callback );
+
+ $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( 42, $result, 'Ability should execute correctly' );
+ }
+
+ /**
+ * Tests that after_execute_ability action is fired with correct parameters.
+ *
+ * @ticket 64098
+ */
+ public function test_after_execute_ability_action() {
+ $action_ability_name = null;
+ $action_input = null;
+ $action_result = null;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'input_schema' => array(
+ 'type' => 'integer',
+ 'description' => 'Test input parameter.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function ( int $input ): int {
+ return $input * 3;
+ },
+ )
+ );
+
+ $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) {
+ $action_ability_name = $ability_name;
+ $action_input = $input;
+ $action_result = $result;
+ };
+
+ add_action( 'wp_after_execute_ability', $callback, 10, 3 );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute( 7 );
+
+ remove_action( 'wp_after_execute_ability', $callback );
+
+ $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( 21, $result, 'Ability should execute correctly' );
+ }
+
+ /**
+ * Tests that after_execute_ability action is fired with null input when no input schema is defined.
+ *
+ * @ticket 64098
+ */
+ public function test_after_execute_ability_action_no_input() {
+ $action_ability_name = null;
+ $action_input = null;
+ $action_result = null;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'output_schema' => array(),
+ 'execute_callback' => static function (): string {
+ return 'test-result';
+ },
+ )
+ );
+
+ $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) {
+ $action_ability_name = $ability_name;
+ $action_input = $input;
+ $action_result = $result;
+ };
+
+ add_action( 'wp_after_execute_ability', $callback, 10, 3 );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute();
+
+ remove_action( 'wp_after_execute_ability', $callback );
+
+ $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( 'test-result', $result, 'Ability should execute correctly' );
+ }
+
+ /**
+ * Tests that neither action is fired when execution fails due to permission issues.
+ *
+ * @ticket 64098
+ */
+ public function test_actions_not_fired_on_permission_failure() {
+ $before_action_fired = false;
+ $after_action_fired = false;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'permission_callback' => static function (): bool {
+ return false;
+ },
+ )
+ );
+
+ $before_callback = static function () use ( &$before_action_fired ) {
+ $before_action_fired = true;
+ };
+
+ $after_callback = static function () use ( &$after_action_fired ) {
+ $after_action_fired = true;
+ };
+
+ add_action( 'wp_before_execute_ability', $before_callback );
+ add_action( 'wp_after_execute_ability', $after_callback );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute();
+
+ remove_action( 'wp_before_execute_ability', $before_callback );
+ remove_action( 'wp_after_execute_ability', $after_callback );
+
+ $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' );
+ $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' );
+ $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error on permission failure' );
+ }
+
+ /**
+ * Tests that after_execute_ability action is not fired when execution callback returns WP_Error.
+ *
+ * @ticket 64098
+ */
+ public function test_after_action_not_fired_on_execution_error() {
+ $before_action_fired = false;
+ $after_action_fired = false;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'execute_callback' => static function () {
+ return new WP_Error( 'test_error', 'Test execution error' );
+ },
+ )
+ );
+
+ $before_callback = static function () use ( &$before_action_fired ) {
+ $before_action_fired = true;
+ };
+
+ $after_callback = static function () use ( &$after_action_fired ) {
+ $after_action_fired = true;
+ };
+
+ add_action( 'wp_before_execute_ability', $before_callback );
+ add_action( 'wp_after_execute_ability', $after_callback );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute();
+
+ remove_action( 'wp_before_execute_ability', $before_callback );
+ remove_action( 'wp_after_execute_ability', $after_callback );
+
+ $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' );
+ $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' );
+ $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error from execution callback' );
+ }
+
+ /**
+ * Tests that after_execute_ability action is not fired when output validation fails.
+ *
+ * @ticket 64098
+ */
+ public function test_after_action_not_fired_on_output_validation_error() {
+ $before_action_fired = false;
+ $after_action_fired = false;
+
+ $args = array_merge(
+ self::$test_ability_properties,
+ array(
+ 'output_schema' => array(
+ 'type' => 'string',
+ 'description' => 'Expected string output.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function (): int {
+ return 42;
+ },
+ )
+ );
+
+ $before_callback = static function () use ( &$before_action_fired ) {
+ $before_action_fired = true;
+ };
+
+ $after_callback = static function () use ( &$after_action_fired ) {
+ $after_action_fired = true;
+ };
+
+ add_action( 'wp_before_execute_ability', $before_callback );
+ add_action( 'wp_after_execute_ability', $after_callback );
+
+ $ability = new WP_Ability( self::$test_ability_name, $args );
+ $result = $ability->execute();
+
+ remove_action( 'wp_before_execute_ability', $before_callback );
+ remove_action( 'wp_after_execute_ability', $after_callback );
+
+ $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' );
+ $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' );
+ $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbility.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpAbilityCategoryRegistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,718 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the ability category functionality.
+ *
+ * @covers WP_Ability_Category
+ * @covers WP_Ability_Categories_Registry
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbilityCategoryRegistry extends WP_UnitTestCase {
+
+ /**
+ * Category registry instance.
+ *
+ * @var WP_Ability_Categories_Registry
+ */
+ private $registry;
+
+ /**
+ * Captured `_doing_it_wrong` calls during a test.
+ *
+ * @var array<int,array{function:string,message:string,version:string}>
+ */
+ private $doing_it_wrong_log = array();
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ $this->registry = new WP_Ability_Categories_Registry();
+ $this->doing_it_wrong_log = array();
+
+ add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) );
+ $this->doing_it_wrong_log = array();
+
+ $this->registry = null;
+
+ parent::tear_down();
+ }
+
+ /**
+ * Records `_doing_it_wrong` calls for later assertions.
+ *
+ * @param string $the_method Function name flagged by `_doing_it_wrong`.
+ * @param string $message Message supplied to `_doing_it_wrong`.
+ * @param string $version Version string supplied to `_doing_it_wrong`.
+ */
+ public function record_doing_it_wrong( string $the_method, string $message, string $version ): void {
+ $this->doing_it_wrong_log[] = array(
+ 'function' => $the_method,
+ 'message' => $message,
+ 'version' => $version,
+ );
+ }
+
+ /**
+ * Asserts that `_doing_it_wrong` was triggered for the expected function.
+ *
+ * @param string $the_method Function name expected to trigger `_doing_it_wrong`.
+ * @param string|null $message_contains Optional. String that should be contained in the error message.
+ */
+ private function assertDoingItWrongTriggered( string $the_method, ?string $message_contains = null ): void {
+ foreach ( $this->doing_it_wrong_log as $entry ) {
+ if ( $the_method === $entry['function'] ) {
+ // If message check is specified, verify it contains the expected text.
+ if ( null !== $message_contains && false === strpos( $entry['message'], $message_contains ) ) {
+ continue;
+ }
+ return;
+ }
+ }
+
+ if ( null !== $message_contains ) {
+ $this->fail(
+ sprintf(
+ 'Failed asserting that _doing_it_wrong() was triggered for %s with message containing "%s".',
+ $the_method,
+ $message_contains
+ )
+ );
+ } else {
+ $this->fail( sprintf( 'Failed asserting that _doing_it_wrong() was triggered for %s.', $the_method ) );
+ }
+ }
+
+ /**
+ * Test registering a valid category.
+ *
+ * @ticket 64098
+ */
+ public function test_register_valid_category(): void {
+ $result = $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( 'test-math', $result->get_slug() );
+ $this->assertSame( 'Math', $result->get_label() );
+ $this->assertSame( 'Mathematical operations.', $result->get_description() );
+ }
+
+ /**
+ * Test registering category with invalid slug format.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_category_invalid_slug_format(): void {
+ // Uppercase characters not allowed.
+ $result = $this->registry->register(
+ 'Test-Math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' );
+ }
+
+ /**
+ * Test registering category with invalid slug - underscore.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_category_invalid_slug_underscore(): void {
+ $result = $this->registry->register(
+ 'test_math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' );
+ }
+
+ /**
+ * Test registering category without label.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_category_missing_label(): void {
+ $result = $this->registry->register(
+ 'test-math',
+ array(
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering category without description.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_category_missing_description(): void {
+ $result = $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering duplicate category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_duplicate_category(): void {
+ $result = $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+
+ $result = $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math 2',
+ 'description' => 'Another math category.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' );
+ }
+
+ /**
+ * Test unregistering existing category.
+ *
+ * @ticket 64098
+ */
+ public function test_unregister_existing_category(): void {
+ $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $result = $this->registry->unregister( 'test-math' );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertFalse( $this->registry->is_registered( 'test-math' ) );
+ }
+
+ /**
+ * Test unregistering non-existent category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister
+ */
+ public function test_unregister_nonexistent_category(): void {
+ $result = $this->registry->unregister( 'test-nonexistent' );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' );
+ }
+
+ /**
+ * Test retrieving existing category.
+ *
+ * @ticket 64098
+ */
+ public function test_get_existing_category(): void {
+ $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $result = $this->registry->get_registered( 'test-math' );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( 'test-math', $result->get_slug() );
+ }
+
+ /**
+ * Test retrieving non-existent category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered
+ */
+ public function test_get_nonexistent_category(): void {
+ $result = $this->registry->get_registered( 'test-nonexistent' );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' );
+ }
+
+ /**
+ * Tests checking if an ability category is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_ability_category(): void {
+ $category_slug = 'test-math';
+ $this->registry->register(
+ $category_slug,
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $result = $this->registry->is_registered( $category_slug );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests checking if a non-existent ability category is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_nonexistent_ability_category(): void {
+ $result = $this->registry->is_registered( 'test/non-existent' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test retrieving all registered categories.
+ *
+ * @ticket 64098
+ */
+ public function test_get_all_categories(): void {
+ $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->registry->register(
+ 'test-system',
+ array(
+ 'label' => 'System',
+ 'description' => 'System operations.',
+ )
+ );
+
+ $categories = $this->registry->get_all_registered();
+
+ $this->assertIsArray( $categories );
+ $this->assertCount( 2, $categories );
+ $this->assertArrayHasKey( 'test-math', $categories );
+ $this->assertArrayHasKey( 'test-system', $categories );
+ }
+
+ /**
+ * Test category is_registered method.
+ *
+ * @ticket 64098
+ */
+ public function test_category_is_registered(): void {
+ $this->assertFalse( $this->registry->is_registered( 'test-math' ) );
+
+ $this->registry->register(
+ 'test-math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertTrue( $this->registry->is_registered( 'test-math' ) );
+ }
+
+ /**
+ * Test category with special characters in label and description.
+ *
+ * @ticket 64098
+ */
+ public function test_category_with_special_characters(): void {
+ $result = $this->registry->register(
+ 'test-special',
+ array(
+ 'label' => 'Math & Science <tag>',
+ 'description' => 'Operations with "quotes" and \'apostrophes\'.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( 'Math & Science <tag>', $result->get_label() );
+ $this->assertSame( 'Operations with "quotes" and \'apostrophes\'.', $result->get_description() );
+ }
+
+ /**
+ * Data provider for valid ability category slugs.
+ *
+ * @return array<int, array<string>> Valid ability category slugs.
+ */
+ public function data_valid_slug_provider(): array {
+ return array(
+ array( 'test-simple' ),
+ array( 'test-multiple-words' ),
+ array( 'test-with-numbers-123' ),
+ array( 'test-a' ),
+ array( 'test-123' ),
+ );
+ }
+
+ /**
+ * Test category slug validation with valid formats.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_valid_slug_provider
+ *
+ * @param string $slug The category slug to test.
+ */
+ public function test_category_slug_valid_formats( string $slug ): void {
+ $result = $this->registry->register(
+ $slug,
+ array(
+ 'label' => 'Test',
+ 'description' => 'Test description.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" );
+ }
+
+ /**
+ * Data provider for invalid ability category slugs.
+ *
+ * @return array<int, array<string>> Invalid ability category slugs.
+ */
+ public function data_invalid_slug_provider(): array {
+ return array(
+ array( 'Test-Uppercase' ),
+ array( 'test_underscore' ),
+ array( 'test.dot' ),
+ array( 'test/slash' ),
+ array( 'test space' ),
+ array( '-test-start-dash' ),
+ array( 'test-end-dash-' ),
+ array( 'test--double-dash' ),
+ );
+ }
+
+ /**
+ * Test category slug validation with invalid formats.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_invalid_slug_provider
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ *
+ * @param string $slug The category slug to test.
+ */
+ public function test_category_slug_invalid_formats( string $slug ): void {
+ $result = $this->registry->register(
+ $slug,
+ array(
+ 'label' => 'Test',
+ 'description' => 'Test description.',
+ )
+ );
+
+ $this->assertNull( $result, "Slug '{$slug}' should be invalid" );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering category with non-string label.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_category_constructor_non_string_label(): void {
+ $result = $this->registry->register(
+ 'test-invalid',
+ array(
+ 'label' => 123, // Integer instead of string
+ 'description' => 'Valid description.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering category with empty label.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_category_constructor_empty_label(): void {
+ $result = $this->registry->register(
+ 'test-invalid',
+ array(
+ 'label' => '',
+ 'description' => 'Valid description.',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering category with non-string description.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_category_constructor_non_string_description(): void {
+ $result = $this->registry->register(
+ 'test-invalid',
+ array(
+ 'label' => 'Valid Label',
+ 'description' => array( 'invalid' ), // Array instead of string
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test registering category with empty description.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_category_constructor_empty_description(): void {
+ $result = $this->registry->register(
+ 'test-invalid',
+ array(
+ 'label' => 'Valid Label',
+ 'description' => '',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+ }
+
+ /**
+ * Test register_ability_category_args filter.
+ *
+ * @ticket 64098
+ */
+ public function test_register_category_args_filter(): void {
+ add_filter(
+ 'wp_register_ability_category_args',
+ static function ( $args, $slug ) {
+ if ( 'test-filtered' === $slug ) {
+ $args['label'] = 'Filtered Label';
+ $args['description'] = 'Filtered Description';
+ }
+ return $args;
+ },
+ 10,
+ 2
+ );
+
+ $result = $this->registry->register(
+ 'test-filtered',
+ array(
+ 'label' => 'Original Label',
+ 'description' => 'Original Description.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( 'Filtered Label', $result->get_label() );
+ $this->assertSame( 'Filtered Description', $result->get_description() );
+ }
+
+ /**
+ * Test that WP_Ability_Category cannot be unserialized.
+ *
+ * @ticket 64098
+ */
+ public function test_category_wakeup_throws_exception(): void {
+ $category = $this->registry->register(
+ 'test-serialize',
+ array(
+ 'label' => 'Test',
+ 'description' => 'Test description.',
+ )
+ );
+
+ $this->expectException( LogicException::class );
+ $serialized = serialize( $category );
+ unserialize( $serialized );
+ }
+
+ /**
+ * Test registering a category with valid meta.
+ *
+ * @ticket 64098
+ */
+ public function test_register_category_with_valid_meta(): void {
+ $meta = array(
+ 'icon' => 'dashicons-calculator',
+ 'priority' => 10,
+ 'custom' => array( 'key' => 'value' ),
+ );
+
+ $result = $this->registry->register(
+ 'test-meta',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ 'meta' => $meta,
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( 'test-meta', $result->get_slug() );
+ $this->assertSame( $meta, $result->get_meta() );
+ }
+
+ /**
+ * Test registering a category with empty meta array.
+ *
+ * @ticket 64098
+ */
+ public function test_register_category_with_empty_meta(): void {
+ $result = $this->registry->register(
+ 'test-empty-meta',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ 'meta' => array(),
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( array(), $result->get_meta() );
+ }
+
+ /**
+ * Test registering a category without meta returns empty array.
+ *
+ * @ticket 64098
+ */
+ public function test_register_category_without_meta_returns_empty_array(): void {
+ $result = $this->registry->register(
+ 'test-no-meta',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( array(), $result->get_meta() );
+ }
+
+ /**
+ * Test registering a category with invalid meta (non-array).
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+ */
+ public function test_register_category_with_invalid_meta(): void {
+ $result = $this->registry->register(
+ 'test-invalid-meta',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ 'meta' => 'invalid-string',
+ )
+ );
+
+ $this->assertNull( $result );
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'valid `meta` array' );
+ }
+
+ /**
+ * Test registering a category with unknown property triggers _doing_it_wrong.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Category::__construct
+ */
+ public function test_register_category_with_unknown_property(): void {
+ $result = $this->registry->register(
+ 'test-unknown-property',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ 'unknown_property' => 'some value',
+ )
+ );
+
+ // Category should still be created.
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ // But _doing_it_wrong should be triggered.
+ $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' );
+ }
+
+ /**
+ * Test category registry singleton.
+ *
+ * @ticket 64098
+ */
+ public function test_category_registry_singleton(): void {
+ $instance1 = WP_Ability_Categories_Registry::get_instance();
+ $instance2 = WP_Ability_Categories_Registry::get_instance();
+
+ $this->assertSame( $instance1, $instance2 );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpRegisterAbilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,652 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Mock used to test a custom ability class.
+ */
+class Mock_Custom_Ability extends WP_Ability {
+ protected function do_execute( $input = null ) {
+ return 9999;
+ }
+}
+
+/**
+ * Tests for registering, unregistering and retrieving abilities.
+ *
+ * @covers wp_register_ability
+ * @covers wp_unregister_ability
+ * @covers wp_get_ability
+ * @covers wp_has_ability
+ * @covers wp_get_all_abilities
+ *
+ * @group abilities-api
+ */
+class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase {
+
+ public static $test_ability_name = 'test/add-numbers';
+ public static $test_ability_args = array();
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ // Fire the init hook to allow test ability category registration.
+ do_action( 'wp_abilities_api_categories_init' );
+ wp_register_ability_category(
+ 'math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations and calculations.',
+ )
+ );
+
+ self::$test_ability_args = array(
+ 'label' => 'Add numbers',
+ 'description' => 'Calculates the result of adding two numbers.',
+ 'category' => 'math',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'a' => array(
+ 'type' => 'number',
+ 'description' => 'First number.',
+ 'required' => true,
+ ),
+ 'b' => array(
+ 'type' => 'number',
+ 'description' => 'Second number.',
+ 'required' => true,
+ ),
+ ),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => array(
+ 'type' => 'number',
+ 'description' => 'The result of adding the two numbers.',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function ( array $input ): int {
+ return $input['a'] + $input['b'];
+ },
+ 'permission_callback' => static function (): bool {
+ return true;
+ },
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ 'destructive' => false,
+ ),
+ 'show_in_rest' => true,
+ ),
+ );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ foreach ( wp_get_abilities() as $ability ) {
+ if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+ continue;
+ }
+
+ wp_unregister_ability( $ability->get_name() );
+ }
+
+ // Clean up registered test ability category.
+ wp_unregister_ability_category( 'math' );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests registering an ability with invalid name.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::register
+ */
+ public function test_register_ability_invalid_name(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_register_ability( 'invalid_name', array() );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests registering an ability when `abilities_api_init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage wp_register_ability
+ */
+ public function test_register_ability_no_abilities_api_init_action(): void {
+ global $wp_actions;
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['wp_abilities_api_init'] );
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['wp_abilities_api_init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests registering an ability when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+ */
+ public function test_register_ability_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests registering a valid ability.
+ *
+ * @ticket 64098
+ */
+ public function test_register_valid_ability(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $expected_annotations = array_merge(
+ self::$test_ability_args['meta']['annotations'],
+ array(
+ 'idempotent' => false,
+ )
+ );
+ $expected_meta = array_merge(
+ self::$test_ability_args['meta'],
+ array(
+ 'annotations' => $expected_annotations,
+ 'show_in_rest' => true,
+ )
+ );
+
+ $this->assertInstanceOf( WP_Ability::class, $result );
+ $this->assertSame( self::$test_ability_name, $result->get_name() );
+ $this->assertSame( self::$test_ability_args['label'], $result->get_label() );
+ $this->assertSame( self::$test_ability_args['description'], $result->get_description() );
+ $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() );
+ $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() );
+ $this->assertEquals( $expected_meta, $result->get_meta() );
+ $this->assertTrue(
+ $result->check_permissions(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ )
+ );
+ $this->assertSame(
+ 5,
+ $result->execute(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ )
+ );
+ }
+
+ /**
+ * Tests executing an ability with no permissions.
+ *
+ * @ticket 64098
+ */
+ public function test_register_ability_no_permissions(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ self::$test_ability_args['permission_callback'] = static function (): bool {
+ return false;
+ };
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $this->assertFalse(
+ $result->check_permissions(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ )
+ );
+
+ $actual = $result->execute(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ );
+ $this->assertWPError(
+ $actual,
+ 'Execution should fail due to no permissions'
+ );
+ $this->assertEquals( 'ability_invalid_permissions', $actual->get_error_code() );
+ }
+
+ /**
+ * Tests registering an ability with a custom ability class.
+ *
+ * @ticket 64098
+ */
+ public function test_register_ability_custom_ability_class(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_register_ability(
+ self::$test_ability_name,
+ array_merge(
+ self::$test_ability_args,
+ array(
+ 'ability_class' => Mock_Custom_Ability::class,
+ )
+ )
+ );
+
+ $this->assertInstanceOf( Mock_Custom_Ability::class, $result );
+ $this->assertSame(
+ 9999,
+ $result->execute(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ )
+ );
+
+ // Try again with an invalid class throws a doing it wrong.
+ $this->setExpectedIncorrectUsage( WP_Abilities_Registry::class . '::register' );
+ wp_register_ability(
+ self::$test_ability_name,
+ array_merge(
+ self::$test_ability_args,
+ array(
+ 'ability_class' => 'Non_Existent_Class',
+ )
+ )
+ );
+ }
+
+ /**
+ * Tests executing an ability with input not matching schema.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_ability_no_input_schema_match(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $actual = $result->execute(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ 'unknown' => 1,
+ )
+ );
+
+ $this->assertWPError(
+ $actual,
+ 'Execution should fail due to input not matching schema.'
+ );
+ $this->assertSame( 'ability_invalid_input', $actual->get_error_code() );
+ $this->assertSame(
+ 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.',
+ $actual->get_error_message()
+ );
+ }
+
+ /**
+ * Tests executing an ability with output not matching schema.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_ability_no_output_schema_match(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ self::$test_ability_args['execute_callback'] = static function (): bool {
+ return true;
+ };
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $actual = $result->execute(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ )
+ );
+ $this->assertWPError(
+ $actual,
+ 'Execution should fail due to output not matching schema.'
+ );
+ $this->assertSame( 'ability_invalid_output', $actual->get_error_code() );
+ $this->assertSame(
+ 'Ability "test/add-numbers" has invalid output. Reason: output is not of type number.',
+ $actual->get_error_message()
+ );
+ }
+
+ /**
+ * Tests input validation failing due to schema mismatch.
+ *
+ * @ticket 64098
+ */
+ public function test_validate_input_no_input_schema_match(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $actual = $result->validate_input(
+ array(
+ 'a' => 2,
+ 'b' => 3,
+ 'unknown' => 1,
+ )
+ );
+
+ $this->assertWPError(
+ $actual,
+ 'Input validation should fail due to input not matching schema.'
+ );
+ $this->assertSame( 'ability_invalid_input', $actual->get_error_code() );
+ $this->assertSame(
+ 'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.',
+ $actual->get_error_message()
+ );
+ }
+
+ /**
+ * Tests permission callback receiving input for contextual permission checks.
+ *
+ * @ticket 64098
+ */
+ public function test_permission_callback_receives_input(): void {
+ do_action( 'wp_abilities_api_init' );
+
+ $received_input = null;
+ self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool {
+ $received_input = $input;
+ // Allow only if 'a' is greater than 'b'
+ return $input['a'] > $input['b'];
+ };
+
+ $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ // Test with a > b (should be allowed)
+ $this->assertTrue(
+ $result->check_permissions(
+ array(
+ 'a' => 5,
+ 'b' => 3,
+ )
+ )
+ );
+ $this->assertSame(
+ array(
+ 'a' => 5,
+ 'b' => 3,
+ ),
+ $received_input
+ );
+
+ // Test with a < b (should be denied)
+ $this->assertFalse(
+ $result->check_permissions(
+ array(
+ 'a' => 2,
+ 'b' => 8,
+ )
+ )
+ );
+ $this->assertSame(
+ array(
+ 'a' => 2,
+ 'b' => 8,
+ ),
+ $received_input
+ );
+ }
+
+ /**
+ * Tests unregistering an ability when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+ */
+ public function test_unregister_ability_no_init_action(): void {
+ global $wp_actions;
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_unregister_ability( self::$test_ability_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests unregistering existing ability.
+ *
+ * @ticket 64098
+ */
+ public function test_unregister_existing_ability() {
+ do_action( 'wp_abilities_api_init' );
+
+ wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $result = wp_unregister_ability( self::$test_ability_name );
+
+ $this->assertEquals(
+ new WP_Ability( self::$test_ability_name, self::$test_ability_args ),
+ $result
+ );
+ }
+
+ /**
+ * Tests retrieving an ability when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+ */
+ public function test_get_ability_no_init_action(): void {
+ global $wp_actions;
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_get_ability( self::$test_ability_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests retrieving existing ability registered with the `wp_abilities_api_init` callback.
+ *
+ * @ticket 64098
+ */
+ public function test_get_existing_ability_using_callback() {
+ $name = self::$test_ability_name;
+ $args = self::$test_ability_args;
+ $callback = static function ( $instance ) use ( $name, $args ) {
+ wp_register_ability( $name, $args );
+ };
+
+ add_action( 'wp_abilities_api_init', $callback );
+
+ // Reset the Registry, to ensure it's empty before the test.
+ $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class );
+ $instance_prop = $registry_reflection->getProperty( 'instance' );
+ if ( PHP_VERSION_ID < 80100 ) {
+ $instance_prop->setAccessible( true );
+ }
+ $instance_prop->setValue( null, null );
+
+ $result = wp_get_ability( $name );
+
+ remove_action( 'wp_abilities_api_init', $callback );
+
+ $this->assertEquals(
+ new WP_Ability( $name, $args ),
+ $result,
+ 'Ability does not share expected properties.'
+ );
+ }
+
+ /**
+ * Tests checking if an ability is registered when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+ */
+ public function test_has_ability_no_init_action(): void {
+ global $wp_actions;
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_has_ability( self::$test_ability_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests checking if an ability is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_ability() {
+ do_action( 'wp_abilities_api_init' );
+
+ wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+ $result = wp_has_ability( self::$test_ability_name );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests checking if a non-existent ability is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_nonexistent_ability() {
+ do_action( 'wp_abilities_api_init' );
+
+ $result = wp_has_ability( 'test/non-existent' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests retrieving all registered abilities when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+ */
+ public function test_get_abilities_no_init_action(): void {
+ global $wp_actions;
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_get_abilities();
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertSame( array(), $result );
+ }
+
+ /**
+ * Tests retrieving all registered abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_get_all_registered_abilities() {
+ do_action( 'wp_abilities_api_init' );
+
+ $ability_one_name = 'test/ability-one';
+ $ability_one_args = self::$test_ability_args;
+ wp_register_ability( $ability_one_name, $ability_one_args );
+
+ $ability_two_name = 'test/ability-two';
+ $ability_two_args = self::$test_ability_args;
+ wp_register_ability( $ability_two_name, $ability_two_args );
+
+ $ability_three_name = 'test/ability-three';
+ $ability_three_args = self::$test_ability_args;
+ wp_register_ability( $ability_three_name, $ability_three_args );
+
+ $expected = array(
+ $ability_one_name => new WP_Ability( $ability_one_name, $ability_one_args ),
+ $ability_two_name => new WP_Ability( $ability_two_name, $ability_two_args ),
+ $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_args ),
+ );
+
+ $result = wp_get_abilities();
+ $this->assertEquals( $expected, $result );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpRegisterAbilityCategoryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,365 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the ability category registry functionality.
+ *
+ * @covers wp_register_ability_category
+ * @covers wp_unregister_ability_category
+ * @covers wp_has_ability_category
+ * @covers wp_get_ability_category
+ * @covers wp_get_ability_categories
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpRegisterAbilityCategory extends WP_UnitTestCase {
+
+ public static $test_ability_category_name = 'test-math';
+ public static $test_ability_category_args = array();
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ self::$test_ability_category_args = array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations.',
+ );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ // Clean up any test ability categories registered during tests.
+ foreach ( wp_get_ability_categories() as $ability_category ) {
+ if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) {
+ continue;
+ }
+
+ wp_unregister_ability_category( $ability_category->get_slug() );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Test registering ability category before `abilities_api_categories_init` hook.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage wp_register_ability_category
+ */
+ public function test_register_category_before_init_hook(): void {
+ $result = wp_register_ability_category(
+ self::$test_ability_category_name,
+ self::$test_ability_category_args
+ );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Tests registering an ability category when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+ */
+ public function test_register_ability_category_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_categories_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_register_ability_category(
+ self::$test_ability_category_name,
+ self::$test_ability_category_args
+ );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Test registering a valid ability category.
+ *
+ * @ticket 64098
+ */
+ public function test_register_valid_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ $result = wp_register_ability_category(
+ self::$test_ability_category_name,
+ self::$test_ability_category_args
+ );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( self::$test_ability_category_name, $result->get_slug() );
+ $this->assertSame( 'Math', $result->get_label() );
+ $this->assertSame( 'Mathematical operations.', $result->get_description() );
+ }
+
+ /**
+ * Tests unregistering an ability category when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+ */
+ public function test_unregister_ability_category_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_categories_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_unregister_ability_category( self::$test_ability_category_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Test unregistering non-existent ability category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister
+ */
+ public function test_unregister_nonexistent_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ $result = wp_unregister_ability_category( 'test-nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Test unregistering existing ability category.
+ *
+ * @ticket 64098
+ */
+ public function test_unregister_existing_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ wp_register_ability_category(
+ self::$test_ability_category_name,
+ self::$test_ability_category_args
+ );
+
+ $result = wp_unregister_ability_category( self::$test_ability_category_name );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertFalse( wp_has_ability_category( self::$test_ability_category_name ) );
+ }
+
+ /**
+ * Tests checking if an ability category is registered when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+ */
+ public function test_has_ability_category_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_categories_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_has_ability_category( self::$test_ability_category_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests checking if a non-existent ability category is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_nonexistent_ability_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ $result = wp_has_ability_category( 'test/non-existent' );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Tests checking if an ability category is registered.
+ *
+ * @ticket 64098
+ */
+ public function test_has_registered_ability_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ $category_slug = self::$test_ability_category_name;
+
+ wp_register_ability_category(
+ $category_slug,
+ self::$test_ability_category_args
+ );
+
+ $result = wp_has_ability_category( $category_slug );
+
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Tests retrieving an ability category when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+ */
+ public function test_get_ability_category_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_categories_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_get_ability_category( self::$test_ability_category_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Test retrieving non-existent ability category.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered
+ */
+ public function test_get_nonexistent_category(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ $result = wp_get_ability_category( 'test-nonexistent' );
+
+ $this->assertNull( $result );
+ }
+
+ /**
+ * Test retrieving existing ability category registered with the `wp_abilities_api_categories_init` callback.
+ *
+ * @ticket 64098
+ */
+ public function test_get_existing_category_using_callback(): void {
+ $name = self::$test_ability_category_name;
+ $args = self::$test_ability_category_args;
+ $callback = static function ( $instance ) use ( $name, $args ) {
+ wp_register_ability_category( $name, $args );
+ };
+
+ add_action( 'wp_abilities_api_categories_init', $callback );
+
+ // Reset the Registry, to ensure it's empty before the test.
+ $registry_reflection = new ReflectionClass( WP_Ability_Categories_Registry::class );
+ $instance_prop = $registry_reflection->getProperty( 'instance' );
+ if ( PHP_VERSION_ID < 80100 ) {
+ $instance_prop->setAccessible( true );
+ }
+ $instance_prop->setValue( null, null );
+
+ $result = wp_get_ability_category( $name );
+
+ remove_action( 'wp_abilities_api_categories_init', $callback );
+
+ $this->assertInstanceOf( WP_Ability_Category::class, $result );
+ $this->assertSame( self::$test_ability_category_name, $result->get_slug() );
+ }
+
+ /**
+ * Test retrieving all registered ability categories when `init` action has not fired.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+ */
+ public function test_get_ability_categories_no_init_action(): void {
+ global $wp_actions;
+
+ do_action( 'wp_abilities_api_categories_init' );
+
+ // Store the original action count.
+ $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+ // Reset the action count to simulate it not being fired.
+ unset( $wp_actions['init'] );
+
+ $result = wp_get_ability_categories( self::$test_ability_category_name );
+
+ // Restore the original action count.
+ if ( $original_count > 0 ) {
+ $wp_actions['init'] = $original_count;
+ }
+
+ $this->assertSame( array(), $result );
+ }
+
+ /**
+ * Test retrieving all registered ability categories.
+ *
+ * @ticket 64098
+ */
+ public function test_get_all_categories(): void {
+ do_action( 'wp_abilities_api_categories_init' );
+
+ wp_register_ability_category(
+ self::$test_ability_category_name,
+ self::$test_ability_category_args
+ );
+
+ wp_register_ability_category(
+ 'test-system',
+ array(
+ 'label' => 'System',
+ 'description' => 'System operations.',
+ )
+ );
+
+ $categories = wp_get_ability_categories();
+
+ $this->assertIsArray( $categories );
+ $this->assertCount( 2, $categories );
+ $this->assertArrayHasKey( self::$test_ability_category_name, $categories );
+ $this->assertArrayHasKey( 'test-system', $categories );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsrestapirestschemasetupphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php 2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -203,6 +203,10 @@
</span><span class="cx" style="display: block; padding: 0 10px"> '/wp/v2/font-families/(?P<font_family_id>[\d]+)/font-faces',
</span><span class="cx" style="display: block; padding: 0 10px"> '/wp/v2/font-families/(?P<font_family_id>[\d]+)/font-faces/(?P<id>[\d]+)',
</span><span class="cx" style="display: block; padding: 0 10px"> '/wp/v2/font-families/(?P<id>[\d]+)',
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ '/wp-abilities/v1',
+ '/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+?)/run',
+ '/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)',
+ '/wp-abilities/v1/abilities',
</ins><span class="cx" style="display: block; padding: 0 10px"> );
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> $this->assertSameSets( $expected_routes, $routes );
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -213,7 +217,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> '/' === $route ||
</span><span class="cx" style="display: block; padding: 0 10px"> preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) ||
</span><span class="cx" style="display: block; padding: 0 10px"> preg_match( '#^/wp/v2(/.+)?$#', $route ) ||
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">- preg_match( '#^/wp-site-health/v1(/.+)?$#', $route )
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) ||
+ preg_match( '#^/wp-abilities/v1(/.+)?$#', $route )
</ins><span class="cx" style="display: block; padding: 0 10px"> );
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span></span></pre></div>
<a id="trunktestsphpunittestsrestapiwpRestAbilitiesV1ListControllerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php (rev 0)
+++ trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,761 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the REST list controller for abilities endpoint.
+ *
+ * @covers WP_REST_Abilities_V1_List_Controller
+ *
+ * @group abilities-api
+ * @group rest-api
+ */
+class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase {
+
+ /**
+ * REST Server instance.
+ *
+ * @var WP_REST_Server
+ */
+ protected $server;
+
+ /**
+ * Test user ID.
+ *
+ * @var int
+ */
+ protected static $user_id;
+
+ /**
+ * Set up before class.
+ */
+ public static function set_up_before_class(): void {
+ parent::set_up_before_class();
+
+ // Create a test user with read capabilities
+ self::$user_id = self::factory()->user->create(
+ array(
+ 'role' => 'subscriber',
+ )
+ );
+
+ // Fire the init hook to allow test ability categories registration.
+ do_action( 'wp_abilities_api_categories_init' );
+ self::register_test_categories();
+ }
+
+ /**
+ * Tear down after class.
+ */
+ public static function tear_down_after_class(): void {
+ // Clean up registered test ability categories.
+ foreach ( array( 'math', 'system', 'general' ) as $slug ) {
+ wp_unregister_ability_category( $slug );
+ }
+
+ parent::tear_down_after_class();
+ }
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ // Set up REST server
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ $this->server = $wp_rest_server;
+
+ do_action( 'rest_api_init' );
+
+ // Initialize Abilities API.
+ do_action( 'wp_abilities_api_init' );
+ $this->register_test_abilities();
+
+ // Set default user for tests
+ wp_set_current_user( self::$user_id );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ // Clean up test abilities.
+ foreach ( wp_get_abilities() as $ability ) {
+ if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+ continue;
+ }
+
+ wp_unregister_ability( $ability->get_name() );
+ }
+
+ // Reset REST server
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tear_down();
+ }
+
+ /**
+ * Register test categories for testing.
+ */
+ public static function register_test_categories(): void {
+ wp_register_ability_category(
+ 'math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations and calculations.',
+ )
+ );
+
+ wp_register_ability_category(
+ 'system',
+ array(
+ 'label' => 'System',
+ 'description' => 'System information and operations.',
+ )
+ );
+
+ wp_register_ability_category(
+ 'general',
+ array(
+ 'label' => 'General',
+ 'description' => 'General purpose abilities.',
+ )
+ );
+ }
+
+ /**
+ * Register test abilities for testing.
+ */
+ private function register_test_abilities(): void {
+ // Register a regular ability.
+ wp_register_ability(
+ 'test/calculator',
+ array(
+ 'label' => 'Calculator',
+ 'description' => 'Performs basic calculations',
+ 'category' => 'math',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'operation' => array(
+ 'type' => 'string',
+ 'enum' => array( 'add', 'subtract', 'multiply', 'divide' ),
+ ),
+ 'a' => array( 'type' => 'number' ),
+ 'b' => array( 'type' => 'number' ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'number',
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ switch ( $input['operation'] ) {
+ case 'add':
+ return $input['a'] + $input['b'];
+ case 'subtract':
+ return $input['a'] - $input['b'];
+ case 'multiply':
+ return $input['a'] * $input['b'];
+ case 'divide':
+ return 0 !== $input['b'] ? $input['a'] / $input['b'] : null;
+ default:
+ return null;
+ }
+ },
+ 'permission_callback' => static function () {
+ return current_user_can( 'read' );
+ },
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Register a read-only ability.
+ wp_register_ability(
+ 'test/system-info',
+ array(
+ 'label' => 'System Info',
+ 'description' => 'Returns system information',
+ 'category' => 'system',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'detail_level' => array(
+ 'type' => 'string',
+ 'enum' => array( 'basic', 'full' ),
+ 'default' => 'basic',
+ ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'php_version' => array( 'type' => 'string' ),
+ 'wp_version' => array( 'type' => 'string' ),
+ ),
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ $info = array(
+ 'php_version' => phpversion(),
+ 'wp_version' => get_bloginfo( 'version' ),
+ );
+ if ( 'full' === ( $input['detail_level'] ?? 'basic' ) ) {
+ $info['memory_limit'] = ini_get( 'memory_limit' );
+ }
+ return $info;
+ },
+ 'permission_callback' => static function () {
+ return current_user_can( 'read' );
+ },
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ ),
+ 'category' => 'system',
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Ability that does not show in REST.
+ wp_register_ability(
+ 'test/not-show-in-rest',
+ array(
+ 'label' => 'Hidden from REST',
+ 'description' => 'It does not show in REST.',
+ 'category' => 'general',
+ 'execute_callback' => static function (): int {
+ return 0;
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ // Register multiple abilities for pagination testing
+ for ( $i = 1; $i <= 60; $i++ ) {
+ wp_register_ability(
+ "test/ability-{$i}",
+ array(
+ 'label' => "Test Ability {$i}",
+ 'description' => "Test ability number {$i}",
+ 'category' => 'general',
+ 'execute_callback' => static function () use ( $i ) {
+ return "Result from ability {$i}";
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+ }
+ }
+
+ /**
+ * Test listing all abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_get_items(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ $this->assertNotEmpty( $data );
+
+ $this->assertCount( 50, $data, 'First page should return exactly 50 items (default per_page)' );
+
+ $ability_names = wp_list_pluck( $data, 'name' );
+ $this->assertContains( 'test/calculator', $ability_names );
+ $this->assertContains( 'test/system-info', $ability_names );
+ $this->assertNotContains( 'test/not-show-in-rest', $ability_names );
+ }
+
+ /**
+ * Test getting a specific ability.
+ *
+ * @ticket 64098
+ */
+ public function test_get_item(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertCount( 7, $data, 'Response should contain all fields.' );
+ $this->assertEquals( 'test/calculator', $data['name'] );
+ $this->assertEquals( 'Calculator', $data['label'] );
+ $this->assertEquals( 'Performs basic calculations', $data['description'] );
+ $this->assertEquals( 'math', $data['category'] );
+ $this->assertArrayHasKey( 'input_schema', $data );
+ $this->assertArrayHasKey( 'output_schema', $data );
+ $this->assertArrayHasKey( 'meta', $data );
+ $this->assertTrue( $data['meta']['show_in_rest'] );
+ }
+
+ /**
+ * Test getting a specific ability with only selected fields.
+ *
+ * @ticket 64098
+ */
+ public function test_get_item_with_selected_fields(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+ $request->set_param( '_fields', 'name,label' );
+ $response = $this->server->dispatch( $request );
+ add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 );
+ $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request );
+ remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertCount( 2, $data, 'Response should only contain the requested fields.' );
+ $this->assertEquals( 'test/calculator', $data['name'] );
+ $this->assertEquals( 'Calculator', $data['label'] );
+ }
+
+ /**
+ * Test getting a specific ability with embed context.
+ *
+ * @ticket 64098
+ */
+ public function test_get_item_with_embed_context(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+ $request->set_param( 'context', 'embed' );
+ $response = $this->server->dispatch( $request );
+ add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 );
+ $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request );
+ remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' );
+ $this->assertEquals( 'test/calculator', $data['name'] );
+ $this->assertEquals( 'Calculator', $data['label'] );
+ $this->assertEquals( 'math', $data['category'] );
+ }
+
+ /**
+ * Test getting a non-existent ability returns 404.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+ */
+ public function test_get_item_not_found(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/non/existent' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+ }
+
+ /**
+ * Test getting an ability that does not show in REST returns 404.
+ *
+ * @ticket 64098
+ */
+ public function test_get_item_not_show_in_rest(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/not-show-in-rest' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+ }
+
+ /**
+ * Test permission check for listing abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_get_items_permission_denied(): void {
+ // Test with non-logged-in user
+ wp_set_current_user( 0 );
+
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ /**
+ * Test pagination headers.
+ *
+ * @ticket 64098
+ */
+ public function test_pagination_headers(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_param( 'per_page', 10 );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
+
+ $total_abilities = count( wp_get_abilities() ) - 1; // Exclude the one that doesn't show in REST.
+ $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] );
+ $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] );
+ }
+
+ /**
+ * Test HEAD method returns empty body with proper headers.
+ *
+ * @ticket 64098
+ */
+ public function test_head_request(): void {
+ $request = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/abilities' );
+ $response = $this->server->dispatch( $request );
+
+ // Verify empty response body
+ $data = $response->get_data();
+ $this->assertEmpty( $data );
+
+ // Verify pagination headers are present
+ $headers = $response->get_headers();
+ $this->assertArrayHasKey( 'X-WP-Total', $headers );
+ $this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
+ }
+
+ /**
+ * Test pagination links.
+ *
+ * @ticket 64098
+ */
+ public function test_pagination_links(): void {
+ // Test first page (should have 'next' link header but no 'prev')
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_param( 'per_page', 10 );
+ $request->set_param( 'page', 1 );
+ $response = $this->server->dispatch( $request );
+
+ $headers = $response->get_headers();
+ $link_header = $headers['Link'] ?? '';
+
+ // Parse Link header for rel="next" and rel="prev"
+ $this->assertStringContainsString( 'rel="next"', $link_header );
+ $this->assertStringNotContainsString( 'rel="prev"', $link_header );
+
+ // Test middle page (should have both 'next' and 'prev' link headers)
+ $request->set_param( 'page', 3 );
+ $response = $this->server->dispatch( $request );
+
+ $headers = $response->get_headers();
+ $link_header = $headers['Link'] ?? '';
+
+ $this->assertStringContainsString( 'rel="next"', $link_header );
+ $this->assertStringContainsString( 'rel="prev"', $link_header );
+
+ // Test last page (should have 'prev' link header but no 'next')
+ $total_abilities = count( wp_get_abilities() );
+ $last_page = ceil( $total_abilities / 10 );
+ $request->set_param( 'page', $last_page );
+ $response = $this->server->dispatch( $request );
+
+ $headers = $response->get_headers();
+ $link_header = $headers['Link'] ?? '';
+
+ $this->assertStringNotContainsString( 'rel="next"', $link_header );
+ $this->assertStringContainsString( 'rel="prev"', $link_header );
+ }
+
+ /**
+ * Test collection parameters.
+ *
+ * @ticket 64098
+ */
+ public function test_collection_params(): void {
+ // Test per_page parameter
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_param( 'per_page', 5 );
+ $response = $this->server->dispatch( $request );
+
+ $data = $response->get_data();
+ $this->assertCount( 5, $data );
+
+ // Test page parameter
+ $request->set_param( 'page', 2 );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertCount( 5, $data );
+
+ // Verify we got different abilities on page 2
+ $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $page1_request->set_param( 'per_page', 5 );
+ $page1_request->set_param( 'page', 1 );
+ $page1_response = $this->server->dispatch( $page1_request );
+ $page1_names = wp_list_pluck( $page1_response->get_data(), 'name' );
+ $page2_names = wp_list_pluck( $data, 'name' );
+
+ $this->assertNotEquals( $page1_names, $page2_names );
+ }
+
+ /**
+ * Test response links for individual abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_ability_response_links(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+ $response = $this->server->dispatch( $request );
+
+ $links = $response->get_links();
+ $this->assertArrayHasKey( 'self', $links );
+ $this->assertArrayHasKey( 'collection', $links );
+ $this->assertArrayHasKey( 'wp:action-run', $links );
+
+ // Verify link URLs
+ $self_link = $links['self'][0]['href'];
+ $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator', $self_link );
+
+ $collection_link = $links['collection'][0]['href'];
+ $this->assertStringContainsString( '/wp-abilities/v1/abilities', $collection_link );
+
+ $run_link = $links['wp:action-run'][0]['href'];
+ $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator/run', $run_link );
+ }
+
+ /**
+ * Test context parameter.
+ *
+ * @ticket 64098
+ */
+ public function test_context_parameter(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+ $request->set_param( 'context', 'view' );
+ $response = $this->server->dispatch( $request );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'description', $data );
+
+ $request->set_param( 'context', 'embed' );
+ $response = $this->server->dispatch( $request );
+
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'name', $data );
+ $this->assertArrayHasKey( 'label', $data );
+ }
+
+ /**
+ * Test schema retrieval.
+ *
+ * @ticket 64098
+ */
+ public function test_get_schema(): void {
+ $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'schema', $data );
+ $schema = $data['schema'];
+
+ $this->assertEquals( 'ability', $schema['title'] );
+ $this->assertEquals( 'object', $schema['type'] );
+ $this->assertArrayHasKey( 'properties', $schema );
+
+ $properties = $schema['properties'];
+
+ // Assert the count of properties to catch when new keys are added
+ $this->assertCount( 7, $properties, 'Schema should have exactly 7 properties. If this fails, update this test to include the new property.' );
+
+ // Check all expected properties exist
+ $this->assertArrayHasKey( 'name', $properties );
+ $this->assertArrayHasKey( 'label', $properties );
+ $this->assertArrayHasKey( 'description', $properties );
+ $this->assertArrayHasKey( 'input_schema', $properties );
+ $this->assertArrayHasKey( 'output_schema', $properties );
+ $this->assertArrayHasKey( 'meta', $properties );
+ $this->assertArrayHasKey( 'category', $properties );
+ }
+
+ /**
+ * Test ability name with valid special characters.
+ *
+ * @ticket 64098
+ */
+ public function test_ability_name_with_valid_special_characters(): void {
+ // Register ability with hyphen (valid).
+ wp_register_ability(
+ 'test-hyphen/ability',
+ array(
+ 'label' => 'Test Hyphen Ability',
+ 'description' => 'Test ability with hyphen',
+ 'category' => 'general',
+ 'execute_callback' => static function ( $input ) {
+ return array( 'success' => true );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Test valid special characters (hyphen, forward slash)
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test-hyphen/ability' );
+ $response = $this->server->dispatch( $request );
+
+ wp_unregister_ability( 'test-hyphen/ability' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Data provider for invalid ability names.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function data_invalid_ability_names_provider(): array {
+ return array(
+ '@ symbol' => array( 'test@ability' ),
+ 'space' => array( 'test ability' ),
+ 'dot' => array( 'test.ability' ),
+ 'hash' => array( 'test#ability' ),
+ 'URL encoded space' => array( 'test%20ability' ),
+ 'angle brackets' => array( 'test<ability>' ),
+ 'pipe' => array( 'test|ability' ),
+ 'backslash' => array( 'test\\ability' ),
+ );
+ }
+
+ /**
+ * Test ability names with invalid special characters.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_invalid_ability_names_provider
+ *
+ * @param string $name Invalid ability name to test.
+ */
+ public function test_ability_name_with_invalid_special_characters( string $name ): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name );
+ $response = $this->server->dispatch( $request );
+ // Should return 404 as the regex pattern won't match
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Test extremely long ability names.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+ */
+ public function test_extremely_long_ability_names(): void {
+ // Create a very long but valid ability name
+ $long_name = 'test/' . str_repeat( 'a', 1000 );
+
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $long_name );
+ $response = $this->server->dispatch( $request );
+
+ // Should return 404 as ability doesn't exist
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * Data provider for invalid pagination parameters.
+ *
+ * @return array<string, array{0: array<string, mixed>}>
+ */
+ public function data_invalid_pagination_params_provider(): array {
+ return array(
+ 'Zero page' => array( array( 'page' => 0 ) ),
+ 'Negative page' => array( array( 'page' => -1 ) ),
+ 'Non-numeric page' => array( array( 'page' => 'abc' ) ),
+ 'Zero per page' => array( array( 'per_page' => 0 ) ),
+ 'Negative per page' => array( array( 'per_page' => -10 ) ),
+ 'Exceeds maximum' => array( array( 'per_page' => 1000 ) ),
+ 'Non-numeric per page' => array( array( 'per_page' => 'all' ) ),
+ );
+ }
+
+ /**
+ * Test pagination parameters with invalid values.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_invalid_pagination_params_provider
+ *
+ * @param array<string, mixed> $params Invalid pagination parameters.
+ */
+ public function test_invalid_pagination_parameters( array $params ): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_query_params( $params );
+
+ $response = $this->server->dispatch( $request );
+
+ // Should either use defaults or return error
+ $this->assertContains( $response->get_status(), array( 200, 400 ) );
+
+ if ( $response->get_status() !== 200 ) {
+ return;
+ }
+
+ // Check that reasonable defaults were used
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ }
+
+ /**
+ * Test filtering abilities by category.
+ *
+ * @ticket 64098
+ */
+ public function test_filter_by_category(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_param( 'category', 'math' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+
+ // Should only have math category abilities
+ foreach ( $data as $ability ) {
+ $this->assertEquals( 'math', $ability['category'], 'All abilities should be in math category' );
+ }
+
+ // Should at least contain the calculator
+ $ability_names = wp_list_pluck( $data, 'name' );
+ $this->assertContains( 'test/calculator', $ability_names );
+ $this->assertNotContains( 'test/system-info', $ability_names, 'System info should not be in math category' );
+ }
+
+ /**
+ * Test filtering by non-existent category returns empty results.
+ *
+ * @ticket 64098
+ */
+ public function test_filter_by_nonexistent_category(): void {
+ // Ensure category doesn't exist - test should fail if it does.
+ $this->assertFalse(
+ wp_has_ability_category( 'nonexistent' ),
+ 'The nonexistent category should not be registered - test isolation may be broken'
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+ $request->set_param( 'category', 'nonexistent' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ $this->assertEmpty( $data, 'Should return empty array for non-existent category' );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsrestapiwpRestAbilitiesV1RunControllerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php (rev 0)
+++ trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,1181 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the REST run controller for abilities endpoint.
+ *
+ * @covers WP_REST_Abilities_V1_Run_Controller
+ *
+ * @group abilities-api
+ * @group rest-api
+ */
+class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase {
+
+ /**
+ * REST Server instance.
+ *
+ * @var WP_REST_Server
+ */
+ protected $server;
+
+ /**
+ * Test user ID with permissions.
+ *
+ * @var int
+ */
+ protected static $user_id;
+
+ /**
+ * Test user ID without permissions.
+ *
+ * @var int
+ */
+ protected static $no_permission_user_id;
+
+ /**
+ * Set up before class.
+ */
+ public static function set_up_before_class(): void {
+ parent::set_up_before_class();
+
+ self::$user_id = self::factory()->user->create(
+ array(
+ 'role' => 'editor',
+ )
+ );
+
+ self::$no_permission_user_id = self::factory()->user->create(
+ array(
+ 'role' => 'subscriber',
+ )
+ );
+
+ // Fire the init hook to allow test ability categories registration.
+ do_action( 'wp_abilities_api_categories_init' );
+ self::register_test_categories();
+ }
+
+ /**
+ * Tear down after class.
+ */
+ public static function tear_down_after_class(): void {
+ // Clean up registered test ability categories.
+ foreach ( array( 'math', 'system', 'general' ) as $slug ) {
+ wp_unregister_ability_category( $slug );
+ }
+
+ parent::tear_down_after_class();
+ }
+
+ /**
+ * Set up before each test.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ global $wp_rest_server;
+ $wp_rest_server = new WP_REST_Server();
+ $this->server = $wp_rest_server;
+
+ do_action( 'rest_api_init' );
+
+ // Initialize Abilities API.
+ do_action( 'wp_abilities_api_init' );
+ $this->register_test_abilities();
+
+ // Set default user for tests
+ wp_set_current_user( self::$user_id );
+ }
+
+ /**
+ * Tear down after each test.
+ */
+ public function tear_down(): void {
+ // Clean up test abilities.
+ foreach ( wp_get_abilities() as $ability ) {
+ if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+ continue;
+ }
+
+ wp_unregister_ability( $ability->get_name() );
+ }
+
+ global $wp_rest_server;
+ $wp_rest_server = null;
+
+ parent::tear_down();
+ }
+
+ /**
+ * Register test categories for testing.
+ */
+ public static function register_test_categories(): void {
+ wp_register_ability_category(
+ 'math',
+ array(
+ 'label' => 'Math',
+ 'description' => 'Mathematical operations and calculations.',
+ )
+ );
+
+ wp_register_ability_category(
+ 'system',
+ array(
+ 'label' => 'System',
+ 'description' => 'System information and operations.',
+ )
+ );
+
+ wp_register_ability_category(
+ 'general',
+ array(
+ 'label' => 'General',
+ 'description' => 'General purpose abilities.',
+ )
+ );
+ }
+
+ /**
+ * Register test abilities for testing.
+ */
+ private function register_test_abilities(): void {
+ // Regular ability (POST only).
+ wp_register_ability(
+ 'test/calculator',
+ array(
+ 'label' => 'Calculator',
+ 'description' => 'Performs calculations',
+ 'category' => 'math',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'a' => array(
+ 'type' => 'number',
+ 'description' => 'First number',
+ ),
+ 'b' => array(
+ 'type' => 'number',
+ 'description' => 'Second number',
+ ),
+ ),
+ 'required' => array( 'a', 'b' ),
+ 'additionalProperties' => false,
+ ),
+ 'output_schema' => array(
+ 'type' => 'number',
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ return $input['a'] + $input['b'];
+ },
+ 'permission_callback' => static function () {
+ return current_user_can( 'edit_posts' );
+ },
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Read-only ability (GET method).
+ wp_register_ability(
+ 'test/user-info',
+ array(
+ 'label' => 'User Info',
+ 'description' => 'Gets user information',
+ 'category' => 'system',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'user_id' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'login' => array( 'type' => 'string' ),
+ ),
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ $user_id = $input['user_id'] ?? get_current_user_id();
+ $user = get_user_by( 'id', $user_id );
+ if ( ! $user ) {
+ return new WP_Error( 'user_not_found', 'User not found' );
+ }
+ return array(
+ 'id' => $user->ID,
+ 'login' => $user->user_login,
+ );
+ },
+ 'permission_callback' => static function () {
+ return is_user_logged_in();
+ },
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Destructive ability (DELETE method).
+ wp_register_ability(
+ 'test/delete-user',
+ array(
+ 'label' => 'Delete User',
+ 'description' => 'Deletes a user',
+ 'category' => 'system',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'user_id' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ ),
+ ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ $user_id = $input['user_id'] ?? get_current_user_id();
+ $user = get_user_by( 'id', $user_id );
+ if ( ! $user ) {
+ return new WP_Error( 'user_not_found', 'User not found' );
+ }
+ return 'User successfully deleted!';
+ },
+ 'permission_callback' => static function () {
+ return is_user_logged_in();
+ },
+ 'meta' => array(
+ 'annotations' => array(
+ 'destructive' => true,
+ 'idempotent' => true,
+ ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Ability with contextual permissions
+ wp_register_ability(
+ 'test/restricted',
+ array(
+ 'label' => 'Restricted Action',
+ 'description' => 'Requires specific input for permission',
+ 'category' => 'general',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'secret' => array( 'type' => 'string' ),
+ 'data' => array( 'type' => 'string' ),
+ ),
+ 'required' => array( 'secret', 'data' ),
+ ),
+ 'output_schema' => array(
+ 'type' => 'string',
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ return 'Success: ' . $input['data'];
+ },
+ 'permission_callback' => static function ( array $input ) {
+ // Only allow if secret matches
+ return isset( $input['secret'] ) && 'valid_secret' === $input['secret'];
+ },
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Ability that does not show in REST.
+ wp_register_ability(
+ 'test/not-show-in-rest',
+ array(
+ 'label' => 'Hidden from REST',
+ 'description' => 'It does not show in REST.',
+ 'category' => 'general',
+ 'execute_callback' => static function (): int {
+ return 0;
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ // Ability that returns null
+ wp_register_ability(
+ 'test/null-return',
+ array(
+ 'label' => 'Null Return',
+ 'description' => 'Returns null',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return null;
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Ability that returns WP_Error
+ wp_register_ability(
+ 'test/error-return',
+ array(
+ 'label' => 'Error Return',
+ 'description' => 'Returns error',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return new WP_Error( 'test_error', 'This is a test error' );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Ability with invalid output
+ wp_register_ability(
+ 'test/invalid-output',
+ array(
+ 'label' => 'Invalid Output',
+ 'description' => 'Returns invalid output',
+ 'category' => 'general',
+ 'output_schema' => array(
+ 'type' => 'number',
+ ),
+ 'execute_callback' => static function () {
+ return 'not a number'; // Invalid - schema expects number
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Read-only ability for query params testing.
+ wp_register_ability(
+ 'test/query-params',
+ array(
+ 'label' => 'Query Params Test',
+ 'description' => 'Tests query parameter handling',
+ 'category' => 'general',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'param1' => array( 'type' => 'string' ),
+ 'param2' => array( 'type' => 'integer' ),
+ ),
+ ),
+ 'execute_callback' => static function ( $input ) {
+ return $input;
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Test executing a regular ability with POST.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_regular_ability_post(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'input' => array(
+ 'a' => 5,
+ 'b' => 3,
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 8, $response->get_data() );
+ }
+
+ /**
+ * Test executing a read-only ability with GET.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_readonly_ability_get(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/user-info/run' );
+ $request->set_query_params(
+ array(
+ 'input' => array(
+ 'user_id' => self::$user_id,
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( self::$user_id, $data['id'] );
+ }
+
+ /**
+ * Test executing a destructive ability with GET.
+ *
+ * @ticket 64098
+ */
+ public function test_execute_destructive_ability_delete(): void {
+ $request = new WP_REST_Request( 'DELETE', '/wp-abilities/v1/abilities/test/delete-user/run' );
+ $request->set_query_params(
+ array(
+ 'input' => array(
+ 'user_id' => self::$user_id,
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'User successfully deleted!', $response->get_data() );
+ }
+
+ /**
+ * Test HTTP method validation for regular abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_regular_ability_requires_post(): void {
+ wp_register_ability(
+ 'test/open-tool',
+ array(
+ 'label' => 'Open Tool',
+ 'description' => 'Tool with no permission requirements',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return 'success';
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/open-tool/run' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 405, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+ $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] );
+ }
+
+ /**
+ * Test HTTP method validation for read-only abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_readonly_ability_requires_get(): void {
+ // Try POST on a read-only ability (should fail).
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/user-info/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 405, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+ $this->assertSame( 'Read-only abilities require GET method.', $data['message'] );
+ }
+
+ /**
+ * Test HTTP method validation for destructive abilities.
+ *
+ * @ticket 64098
+ */
+ public function test_destructive_ability_requires_delete(): void {
+ // Try POST on a destructive ability (should fail).
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/delete-user/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 405, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+ $this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] );
+ }
+
+ /**
+ * Test output validation against schema.
+ * Note: When output validation fails in WP_Ability::execute(), it returns null,
+ * which causes the REST controller to return 'ability_invalid_output'.
+ *
+ * @ticket 64098
+ */
+ public function test_output_validation(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/invalid-output/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'ability_invalid_output', $data['code'] );
+ $this->assertSame(
+ 'Ability "test/invalid-output" has invalid output. Reason: output is not of type number.',
+ $data['message']
+ );
+ }
+
+ /**
+ * Test permission check for execution.
+ *
+ * @ticket 64098
+ */
+ public function test_execution_permission_denied(): void {
+ wp_set_current_user( self::$no_permission_user_id );
+
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'input' => array(
+ 'a' => 5,
+ 'b' => 3,
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 403, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'rest_ability_cannot_execute', $data['code'] );
+ $this->assertSame( 'Sorry, you are not allowed to execute this ability.', $data['message'] );
+ }
+
+ /**
+ * Test contextual permission check.
+ *
+ * @ticket 64098
+ */
+ public function test_contextual_permission_check(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/restricted/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'input' => array(
+ 'secret' => 'wrong_secret',
+ 'data' => 'test data',
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 403, $response->get_status() );
+
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'input' => array(
+ 'secret' => 'valid_secret',
+ 'data' => 'test data',
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'Success: test data', $response->get_data() );
+ }
+
+ /**
+ * Test handling an ability that does not show in REST.
+ *
+ * @ticket 64098
+ */
+ public function test_do_not_show_in_rest(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/not-show-in-rest/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+ $this->assertEquals( 'Ability not found.', $data['message'] );
+ }
+
+ /**
+ * Test handling of null is a valid return value.
+ *
+ * @ticket 64098
+ */
+ public function test_null_return_handling(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/null-return/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertNull( $data );
+ }
+
+ /**
+ * Test handling of WP_Error return from ability.
+ *
+ * @ticket 64098
+ */
+ public function test_wp_error_return_handling(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/error-return/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'test_error', $data['code'] );
+ $this->assertEquals( 'This is a test error', $data['message'] );
+ }
+
+ /**
+ * Test non-existent ability returns 404.
+ *
+ * @ticket 64098
+ *
+ * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+ */
+ public function test_execute_non_existent_ability(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/non/existent/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 404, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+ }
+
+ /**
+ * Test schema retrieval for run endpoint.
+ *
+ * @ticket 64098
+ */
+ public function test_run_endpoint_schema(): void {
+ $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'schema', $data );
+ $schema = $data['schema'];
+
+ $this->assertEquals( 'ability-execution', $schema['title'] );
+ $this->assertEquals( 'object', $schema['type'] );
+ $this->assertArrayHasKey( 'properties', $schema );
+ $this->assertArrayHasKey( 'result', $schema['properties'] );
+ }
+
+ /**
+ * Test that invalid JSON in POST body is handled correctly.
+ *
+ * @ticket 64098
+ */
+ public function test_invalid_json_in_post_body(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ // Set raw body with invalid JSON
+ $request->set_body( '{"input": {invalid json}' );
+
+ $response = $this->server->dispatch( $request );
+
+ // When JSON is invalid, WordPress returns 400 Bad Request
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test GET request with complex nested input array.
+ *
+ * @ticket 64098
+ */
+ public function test_get_request_with_nested_input_array(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' );
+ $request->set_query_params(
+ array(
+ 'input' => array(
+ 'level1' => array(
+ 'level2' => array(
+ 'value' => 'nested',
+ ),
+ ),
+ 'array' => array( 1, 2, 3 ),
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'nested', $data['level1']['level2']['value'] );
+ $this->assertEquals( array( 1, 2, 3 ), $data['array'] );
+ }
+
+ /**
+ * Test GET request with non-array input parameter.
+ *
+ * @ticket 64098
+ */
+ public function test_get_request_with_non_array_input(): void {
+ $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' );
+ $request->set_query_params(
+ array(
+ 'input' => 'not-an-array', // String instead of array
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ // When input is not an array, WordPress returns 400 Bad Request
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test POST request with non-array input in JSON body.
+ *
+ * @ticket 64098
+ */
+ public function test_post_request_with_non_array_input(): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'input' => 'string-value', // String instead of array
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ // When input is not an array, WordPress returns 400 Bad Request
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test ability with invalid output that fails validation.
+ *
+ * @ticket 64098
+ */
+ public function test_output_validation_failure_returns_error(): void {
+ // Register ability with strict output schema.
+ wp_register_ability(
+ 'test/strict-output',
+ array(
+ 'label' => 'Strict Output',
+ 'description' => 'Ability with strict output schema',
+ 'category' => 'general',
+ 'output_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => array( 'success', 'failure' ),
+ ),
+ ),
+ 'required' => array( 'status' ),
+ ),
+ 'execute_callback' => static function () {
+ // Return invalid output that doesn't match schema
+ return array( 'wrong_field' => 'value' );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-output/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->server->dispatch( $request );
+
+ // Should return error when output validation fails.
+ $this->assertSame( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'ability_invalid_output', $data['code'] );
+ $this->assertSame(
+ 'Ability "test/strict-output" has invalid output. Reason: status is a required property of output.',
+ $data['message']
+ );
+ }
+
+ /**
+ * Test ability with invalid input that fails validation.
+ *
+ * @ticket 64098
+ */
+ public function test_input_validation_failure_returns_error(): void {
+ // Register ability with strict input schema.
+ wp_register_ability(
+ 'test/strict-input',
+ array(
+ 'label' => 'Strict Input',
+ 'description' => 'Ability with strict input schema',
+ 'category' => 'general',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'required_field' => array(
+ 'type' => 'string',
+ ),
+ ),
+ 'required' => array( 'required_field' ),
+ ),
+ 'execute_callback' => static function () {
+ return array( 'status' => 'success' );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-input/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ // Missing required field
+ $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) );
+
+ $response = $this->server->dispatch( $request );
+
+ // Should return error when input validation fails.
+ $this->assertSame( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'ability_invalid_input', $data['code'] );
+ $this->assertSame(
+ 'Ability "test/strict-input" has invalid input. Reason: required_field is a required property of input.',
+ $data['message']
+ );
+ }
+
+ /**
+ * Test ability without annotations defaults to POST method.
+ *
+ * @ticket 64098
+ */
+ public function test_ability_without_annotations_defaults_to_post_method(): void {
+ // Register ability without annotations.
+ wp_register_ability(
+ 'test/no-annotations',
+ array(
+ 'label' => 'No Annotations',
+ 'description' => 'Ability without annotations.',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return array( 'executed' => true );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Should require POST (default behavior).
+ $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-annotations/run' );
+ $get_response = $this->server->dispatch( $get_request );
+ $this->assertEquals( 405, $get_response->get_status() );
+
+ // Should work with POST.
+ $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/no-annotations/run' );
+ $post_request->set_header( 'Content-Type', 'application/json' );
+
+ $post_response = $this->server->dispatch( $post_request );
+ $this->assertEquals( 200, $post_response->get_status() );
+ }
+
+ /**
+ * Test edge case with empty input for both GET and POST methods.
+ *
+ * @ticket 64098
+ */
+ public function test_empty_input_handling(): void {
+ // Registers abilities for empty input testing.
+ wp_register_ability(
+ 'test/read-only-empty',
+ array(
+ 'label' => 'Read-only Empty',
+ 'description' => 'Read-only with empty input.',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return array( 'input_was_empty' => 0 === func_num_args() );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'annotations' => array(
+ 'readonly' => true,
+ ),
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ wp_register_ability(
+ 'test/regular-empty',
+ array(
+ 'label' => 'Regular Empty',
+ 'description' => 'Regular with empty input.',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return array( 'input_was_empty' => 0 === func_num_args() );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ // Tests GET with no input parameter.
+ $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty/run' );
+ $get_response = $this->server->dispatch( $get_request );
+ $this->assertEquals( 200, $get_response->get_status() );
+ $this->assertTrue( $get_response->get_data()['input_was_empty'] );
+
+ // Tests POST with no body.
+ $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/regular-empty/run' );
+ $post_request->set_header( 'Content-Type', 'application/json' );
+ $post_request->set_body( '{}' ); // Empty JSON object
+
+ $post_response = $this->server->dispatch( $post_request );
+ $this->assertEquals( 200, $post_response->get_status() );
+ $this->assertTrue( $post_response->get_data()['input_was_empty'] );
+ }
+
+ /**
+ * Data provider for malformed JSON tests.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function data_malformed_json_provider(): array {
+ return array(
+ 'Missing value' => array( '{"input": }' ),
+ 'Trailing comma in array' => array( '{"input": [1, 2, }' ),
+ 'Missing quotes on key' => array( '{input: {}}' ),
+ 'JavaScript undefined' => array( '{"input": undefined}' ),
+ 'JavaScript NaN' => array( '{"input": NaN}' ),
+ 'Missing quotes nested keys' => array( '{"input": {a: 1, b: 2}}' ),
+ 'Single quotes' => array( '\'{"input": {}}\'' ),
+ 'Unclosed object' => array( '{"input": {"key": "value"' ),
+ );
+ }
+
+ /**
+ * Test malformed JSON in POST body.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_malformed_json_provider
+ *
+ * @param string $json Malformed JSON to test.
+ */
+ public function test_malformed_json_post_body( string $json ): void {
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( $json );
+
+ $response = $this->server->dispatch( $request );
+
+ // Malformed JSON should result in 400 Bad Request
+ $this->assertEquals( 400, $response->get_status() );
+ }
+
+ /**
+ * Test input with various PHP types as strings.
+ *
+ * @ticket 64098
+ */
+ public function test_php_type_strings_in_input(): void {
+ // Register ability that accepts any input
+ wp_register_ability(
+ 'test/echo',
+ array(
+ 'label' => 'Echo',
+ 'description' => 'Echoes input',
+ 'category' => 'general',
+ 'input_schema' => array(
+ 'type' => 'object',
+ ),
+ 'execute_callback' => static function ( $input ) {
+ return array( 'echo' => $input );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $inputs = array(
+ 'null' => null,
+ 'true' => true,
+ 'false' => false,
+ 'int' => 123,
+ 'float' => 123.456,
+ 'string' => 'test',
+ 'empty' => '',
+ 'zero' => 0,
+ 'negative' => -1,
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( $inputs, $data['echo'] );
+ }
+
+ /**
+ * Test input with mixed encoding.
+ *
+ * @ticket 64098
+ */
+ public function test_mixed_encoding_in_input(): void {
+ // Register ability that accepts any input
+ wp_register_ability(
+ 'test/echo-encoding',
+ array(
+ 'label' => 'Echo Encoding',
+ 'description' => 'Echoes input with encoding',
+ 'category' => 'general',
+ 'input_schema' => array(
+ 'type' => 'object',
+ ),
+ 'execute_callback' => static function ( $input ) {
+ return array( 'echo' => $input );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $input = array(
+ 'utf8' => 'Hello δΈη',
+ 'emoji' => 'πππ',
+ 'html' => '<script>alert("xss")</script>',
+ 'encoded' => '<test>',
+ 'newlines' => "line1\nline2\rline3\r\nline4",
+ 'tabs' => "col1\tcol2\tcol3",
+ 'quotes' => "It's \"quoted\"",
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo-encoding/run' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'input' => $input ) ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ // Input should be preserved exactly
+ $this->assertEquals( $input['utf8'], $data['echo']['utf8'] );
+ $this->assertEquals( $input['emoji'], $data['echo']['emoji'] );
+ $this->assertEquals( $input['html'], $data['echo']['html'] );
+ }
+
+ /**
+ * Data provider for invalid HTTP methods.
+ *
+ * @return array<string, array{0: string}>
+ */
+ public function data_invalid_http_methods_provider(): array {
+ return array(
+ 'PATCH' => array( 'PATCH' ),
+ 'PUT' => array( 'PUT' ),
+ 'DELETE' => array( 'DELETE' ),
+ 'HEAD' => array( 'HEAD' ),
+ );
+ }
+
+ /**
+ * Test request with invalid HTTP methods.
+ *
+ * @ticket 64098
+ *
+ * @dataProvider data_invalid_http_methods_provider
+ *
+ * @param string $method HTTP method to test.
+ */
+ public function test_invalid_http_methods( string $method ): void {
+ // Register an ability with no permission requirements for this test
+ wp_register_ability(
+ 'test/method-test',
+ array(
+ 'label' => 'Method Test',
+ 'description' => 'Test ability for HTTP method validation',
+ 'category' => 'general',
+ 'execute_callback' => static function () {
+ return array( 'success' => true );
+ },
+ 'permission_callback' => '__return_true', // No permission requirements
+ 'meta' => array(
+ 'show_in_rest' => true,
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( $method, '/wp-abilities/v1/abilities/test/method-test/run' );
+ $response = $this->server->dispatch( $request );
+
+ // Regular abilities should only accept POST, so these should return 405.
+ $this->assertSame( 405, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+ $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] );
+ }
+
+ /**
+ * Test OPTIONS method handling.
+ *
+ * @ticket 64098
+ */
+ public function test_options_method_handling(): void {
+ $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' );
+ $response = $this->server->dispatch( $request );
+ // OPTIONS requests return 200 with allowed methods
+ $this->assertEquals( 200, $response->get_status() );
+ }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsqunitfixtureswpapigeneratedjs"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/qunit/fixtures/wp-api-generated.js</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/qunit/fixtures/wp-api-generated.js 2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/tests/qunit/fixtures/wp-api-generated.js 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -19,7 +19,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> "oembed/1.0",
</span><span class="cx" style="display: block; padding: 0 10px"> "wp/v2",
</span><span class="cx" style="display: block; padding: 0 10px"> "wp-site-health/v1",
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">- "wp-block-editor/v1"
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ "wp-block-editor/v1",
+ "wp-abilities/v1"
</ins><span class="cx" style="display: block; padding: 0 10px"> ],
</span><span class="cx" style="display: block; padding: 0 10px"> "authentication": {
</span><span class="cx" style="display: block; padding: 0 10px"> "application-passwords": {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -13481,6 +13482,153 @@
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> ]
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ },
+ "/wp-abilities/v1": {
+ "namespace": "wp-abilities/v1",
+ "methods": [
+ "GET"
+ ],
+ "endpoints": [
+ {
+ "methods": [
+ "GET"
+ ],
+ "args": {
+ "namespace": {
+ "default": "wp-abilities/v1",
+ "required": false
+ },
+ "context": {
+ "default": "view",
+ "required": false
+ }
+ }
+ }
+ ],
+ "_links": {
+ "self": [
+ {
+ "href": "http://example.org/index.php?rest_route=/wp-abilities/v1"
+ }
+ ]
+ }
+ },
+ "/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\\-\\/]+?)/run": {
+ "namespace": "wp-abilities/v1",
+ "methods": [
+ "GET",
+ "POST",
+ "PUT",
+ "PATCH",
+ "DELETE"
+ ],
+ "endpoints": [
+ {
+ "methods": [
+ "GET",
+ "POST",
+ "PUT",
+ "PATCH",
+ "DELETE"
+ ],
+ "args": {
+ "name": {
+ "description": "Unique identifier for the ability.",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9\\-\\/]+$",
+ "required": false
+ },
+ "input": {
+ "description": "Input parameters for the ability execution.",
+ "type": [
+ "integer",
+ "number",
+ "boolean",
+ "string",
+ "array",
+ "object",
+ "null"
+ ],
+ "default": null,
+ "required": false
+ }
+ }
+ }
+ ]
+ },
+ "/wp-abilities/v1/abilities": {
+ "namespace": "wp-abilities/v1",
+ "methods": [
+ "GET"
+ ],
+ "endpoints": [
+ {
+ "methods": [
+ "GET"
+ ],
+ "args": {
+ "context": {
+ "description": "Scope under which the request is made; determines fields present in response.",
+ "type": "string",
+ "enum": [
+ "view",
+ "embed",
+ "edit"
+ ],
+ "default": "view",
+ "required": false
+ },
+ "page": {
+ "description": "Current page of the collection.",
+ "type": "integer",
+ "default": 1,
+ "minimum": 1,
+ "required": false
+ },
+ "per_page": {
+ "description": "Maximum number of items to be returned in result set.",
+ "type": "integer",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 100,
+ "required": false
+ },
+ "category": {
+ "description": "Limit results to abilities in specific ability category.",
+ "type": "string",
+ "required": false
+ }
+ }
+ }
+ ],
+ "_links": {
+ "self": [
+ {
+ "href": "http://example.org/index.php?rest_route=/wp-abilities/v1/abilities"
+ }
+ ]
+ }
+ },
+ "/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\\-\\/]+)": {
+ "namespace": "wp-abilities/v1",
+ "methods": [
+ "GET"
+ ],
+ "endpoints": [
+ {
+ "methods": [
+ "GET"
+ ],
+ "args": {
+ "name": {
+ "description": "Unique identifier for the ability.",
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9\\-\\/]+$",
+ "required": false
+ }
+ }
+ }
+ ]
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> },
</span><span class="cx" style="display: block; padding: 0 10px"> "site_logo": 0,
</span></span></pre>
</div>
</div>
</body>
</html>