From 4a9c4d7448d7b03464c723f3774c998c4ab77f4c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 09:50:09 -0600 Subject: [PATCH 01/29] Add the base Title Generation class --- .../Title_Generation/Title_Generation.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 includes/Features/Title_Generation/Title_Generation.php diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php new file mode 100644 index 0000000..95a8f6f --- /dev/null +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -0,0 +1,87 @@ + 'title-generation', + 'label' => __( 'Title Generation', 'ai' ), + 'description' => __( 'Generates title suggestions from content.', 'ai' ), + ); + } + + /** + * Registers the feature hooks. + * + * @since 0.1.0 + */ + public function register(): void { + add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); + } + + /** + * Registers the title generation REST API route. + * + * @since 0.1.0 + */ + public function register_rest_route(): void { + register_rest_route( + 'ai/v1', + '/title-generation', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'rest_endpoint_callback' ), + 'permission_callback' => array( $this, 'rest_permission_callback' ), + ) + ); + } + + /** + * Callback for the title generation REST endpoint. + * + * @since 0.1.0 + * + * @return array + */ + public function rest_endpoint_callback(): array { + return array( + 'feature_id' => $this->get_id(), + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'enabled' => $this->is_enabled(), + 'message' => __( 'Title generation feature is active!', 'ai' ), + ); + } + + /** + * Permission check for the REST endpoint. + * + * @since 0.1.0 + * + * @return bool + */ + public function rest_permission_callback(): bool { + return current_user_can( 'manage_options' ); + } +} From 475a8276384140c1bbcd122bb2786baf8a6431bd Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 09:51:57 -0600 Subject: [PATCH 02/29] Load Title Generation by default --- includes/Feature_Loader.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/Feature_Loader.php b/includes/Feature_Loader.php index 14500e3..aba8cf0 100644 --- a/includes/Feature_Loader.php +++ b/includes/Feature_Loader.php @@ -103,6 +103,7 @@ public function register_default_features(): void { private function get_default_features(): array { $feature_classes = array( 'WordPress\AI\Features\Example_Feature\Example_Feature', + 'WordPress\AI\Features\Title_Generation\Title_Generation', ); /** From dae9ef6d1e8f2858d769aea3d54ef7e73121b171 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 11:12:37 -0600 Subject: [PATCH 03/29] Update the Abilities API to the latest 0.4.0 release --- composer.json | 2 +- composer.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 58f3169..e5b53a0 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "automattic/jetpack-autoloader": "^5.0", "ext-json": "*", "php": ">=7.4", - "wordpress/abilities-api": "^0.4.0-rc", + "wordpress/abilities-api": "^0.4.0", "wordpress/mcp-adapter": "dev-trunk", "wordpress/wp-ai-client": "dev-trunk" }, diff --git a/composer.lock b/composer.lock index 2feba3f..e965116 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ce5ebe43c6d811e5f62e93a397a25621", + "content-hash": "f275018de0bc539ae98c534ab9e40c5a", "packages": [ { "name": "automattic/jetpack-autoloader", @@ -476,16 +476,16 @@ }, { "name": "wordpress/abilities-api", - "version": "v0.4.0-rc", + "version": "v0.4.0", "source": { "type": "git", "url": "https://github.com/WordPress/abilities-api.git", - "reference": "c5b5b1b900c5748ba4a5e615d448b2b6c2a756da" + "reference": "0759075aed37c4247adbf273bdebec096d52e825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/c5b5b1b900c5748ba4a5e615d448b2b6c2a756da", - "reference": "c5b5b1b900c5748ba4a5e615d448b2b6c2a756da", + "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/0759075aed37c4247adbf273bdebec096d52e825", + "reference": "0759075aed37c4247adbf273bdebec096d52e825", "shasum": "" }, "require": { @@ -545,7 +545,7 @@ "issues": "https://github.com/WordPress/abilities-api/issues", "source": "https://github.com/WordPress/abilities-api" }, - "time": "2025-10-27T17:04:11+00:00" + "time": "2025-10-29T05:35:31+00:00" }, { "name": "wordpress/mcp-adapter", From 96912e2f49bdd46720a4b03972c478fd3dbe5517 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 11:13:23 -0600 Subject: [PATCH 04/29] Register a custom Abilities category and register title generation as an ability. Make a few updates to our base REST endpoint --- .../Title_Generation/Title_Generation.php | 98 +++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 95a8f6f..0cbb0d7 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -8,6 +8,7 @@ namespace WordPress\AI\Features\Title_Generation; use WordPress\AI\Abstracts\Abstract_Feature; +use WP_REST_Server; /** * Title generation feature. @@ -37,9 +38,76 @@ protected function load_feature_metadata(): array { * @since 0.1.0 */ public function register(): void { + add_action( 'wp_abilities_api_categories_init', array( $this, 'register_categories' ) ); + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); } + /** + * Registers the categories. + * + * TODO: If we want to use the same category for all abilities + * in this plugin, this should be moved out of this class into + * it's own category registration class. + * + * @since 0.1.0 + */ + public function register_categories(): void { + wp_register_ability_category( + 'ai-experiments', + array( + 'label' => __( 'AI Experiments', 'ai' ), + 'description' => __( 'Various AI experiment features.', 'ai' ), + ), + ); + } + + /** + * Registers any needed abilities. + * + * @since 0.1.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/title-generation', // TODO: add a method to build this slug from the feature ID. + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'category' => 'ai-experiments', // TODO: add a method to get the category slug. + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => __( 'Content to generate title suggestions for.', 'ai' ), + ), + ), + 'required' => [ + 'content', + ], + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'titles' => array( + 'type' => 'array', + 'description' => __( 'Generated title suggestions.', 'ai' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + 'execute_callback' => array( $this, 'title_generation_callback' ), + 'permission_callback' => array( $this, 'title_generation_permission_callback' ), + 'meta' => array( + 'show_in_rest' => true, + ), + ), + ); + } + /** * Registers the title generation REST API route. * @@ -48,11 +116,20 @@ public function register(): void { public function register_rest_route(): void { register_rest_route( 'ai/v1', - '/title-generation', + 'title-generation', array( - 'methods' => 'GET', - 'callback' => array( $this, 'rest_endpoint_callback' ), - 'permission_callback' => array( $this, 'rest_permission_callback' ), + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'title_generation_callback' ), + 'permission_callback' => array( $this, 'title_generation_permission_callback' ), + 'args' => [ + 'content' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), + ], + ], ) ); } @@ -62,14 +139,23 @@ public function register_rest_route(): void { * * @since 0.1.0 * + * @param array $input The request data. * @return array */ - public function rest_endpoint_callback(): array { + public function title_generation_callback( array $input ): array { + $args = wp_parse_args( + $input, + [ + 'content' => null, + ] + ); + return array( 'feature_id' => $this->get_id(), 'label' => $this->get_label(), 'description' => $this->get_description(), 'enabled' => $this->is_enabled(), + 'content' => $args['content'], 'message' => __( 'Title generation feature is active!', 'ai' ), ); } @@ -81,7 +167,7 @@ public function rest_endpoint_callback(): array { * * @return bool */ - public function rest_permission_callback(): bool { + public function title_generation_permission_callback(): bool { return current_user_can( 'manage_options' ); } } From 82319aa938335bd19d39a3b7bc4a698ee8bd3ca4 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 11:13:53 -0600 Subject: [PATCH 05/29] Remove the custom REST endpoint as we can use the Abilities endpoint for the same purpose --- .../Title_Generation/Title_Generation.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 0cbb0d7..0879391 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -40,7 +40,6 @@ protected function load_feature_metadata(): array { public function register(): void { add_action( 'wp_abilities_api_categories_init', array( $this, 'register_categories' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); - add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); } /** @@ -108,32 +107,6 @@ public function register_abilities(): void { ); } - /** - * Registers the title generation REST API route. - * - * @since 0.1.0 - */ - public function register_rest_route(): void { - register_rest_route( - 'ai/v1', - 'title-generation', - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'title_generation_callback' ), - 'permission_callback' => array( $this, 'title_generation_permission_callback' ), - 'args' => [ - 'content' => [ - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'validate_callback' => 'rest_validate_request_arg', - 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), - ], - ], - ) - ); - } - /** * Callback for the title generation REST endpoint. * From 8b64bc9206fd8626c2dce5e30115eb01ac81e73f Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 12:22:43 -0600 Subject: [PATCH 06/29] Add some base unit tests --- .../Includes/Feature_LoaderTest.php | 11 +++- .../Title_Generation/Title_GenerationTest.php | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php diff --git a/tests/Integration/Includes/Feature_LoaderTest.php b/tests/Integration/Includes/Feature_LoaderTest.php index 1d36dcd..a103955 100644 --- a/tests/Integration/Includes/Feature_LoaderTest.php +++ b/tests/Integration/Includes/Feature_LoaderTest.php @@ -82,7 +82,7 @@ public function setUp(): void { } /** - * Test register_default_features registers Example_Feature. + * Test register_default_features registers default features. * * @since 0.1.0 */ @@ -94,9 +94,18 @@ public function test_register_default_features() { 'Example feature should be registered' ); + $this->assertTrue( + $this->registry->has_feature( 'title-generation' ), + 'Title generation feature should be registered' + ); + $feature = $this->registry->get_feature( 'example-feature' ); $this->assertNotNull( $feature, 'Example feature should exist' ); $this->assertEquals( 'example-feature', $feature->get_id() ); + + $feature = $this->registry->get_feature( 'title-generation' ); + $this->assertNotNull( $feature, 'Title generation feature should exist' ); + $this->assertEquals( 'title-generation', $feature->get_id() ); } /** diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php new file mode 100644 index 0000000..7e40f39 --- /dev/null +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -0,0 +1,59 @@ +register_default_features(); + + $feature = $registry->get_feature( 'title-generation' ); + $this->assertInstanceOf( Title_Generation::class, $feature, 'Title generation feature should be registered in the registry.' ); + } + + /** + * Tear down test case. + * + * @since 0.1.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that the feature is registered correctly. + * + * @since 0.1.0 + */ + public function test_feature_registration() { + $feature = new Title_Generation(); + + $this->assertEquals( 'title-generation', $feature->get_id() ); + $this->assertEquals( 'Title Generation', $feature->get_label() ); + $this->assertTrue( $feature->is_enabled() ); + } +} From 5de77544bdcaef599e5fb6686103b343d38990a2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 12:22:55 -0600 Subject: [PATCH 07/29] Ignore PHPStan errors that aren't real --- phpstan.neon.dist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c482cd9..7d9e38b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -28,3 +28,7 @@ parameters: - vendor/ analyseAndScan: - node_modules (?) + ignoreErrors: + # These functions exist in the WordPress Abilities API plugin, but are not yet defined in the WordPress core. + - '#Function wp_register_ability_category not found.#' + - '#Function wp_register_ability not found.#' From 4f1f1c47c2b2a509d3758463e98fa32010e45f24 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 12:23:08 -0600 Subject: [PATCH 08/29] Minor cleanup --- .../Title_Generation/Title_Generation.php | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 0879391..9f42364 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -8,7 +8,6 @@ namespace WordPress\AI\Features\Title_Generation; use WordPress\AI\Abstracts\Abstract_Feature; -use WP_REST_Server; /** * Title generation feature. @@ -18,7 +17,7 @@ class Title_Generation extends Abstract_Feature { /** - * Loads feature metadata. + * Load feature metadata. * * @since 0.1.0 * @@ -33,7 +32,7 @@ protected function load_feature_metadata(): array { } /** - * Registers the feature hooks. + * Register any needed hooks. * * @since 0.1.0 */ @@ -43,7 +42,7 @@ public function register(): void { } /** - * Registers the categories. + * Registers needed ability categories. * * TODO: If we want to use the same category for all abilities * in this plugin, this should be moved out of this class into @@ -82,9 +81,9 @@ public function register_abilities(): void { 'description' => __( 'Content to generate title suggestions for.', 'ai' ), ), ), - 'required' => [ + 'required' => array( 'content', - ], + ), ), 'output_schema' => array( 'type' => 'object', @@ -108,7 +107,7 @@ public function register_abilities(): void { } /** - * Callback for the title generation REST endpoint. + * Callback for the title generation abilities endpoint. * * @since 0.1.0 * @@ -118,11 +117,13 @@ public function register_abilities(): void { public function title_generation_callback( array $input ): array { $args = wp_parse_args( $input, - [ + array( 'content' => null, - ] + ), ); + // TODO: Implement the title generation logic. + return array( 'feature_id' => $this->get_id(), 'label' => $this->get_label(), @@ -134,13 +135,13 @@ public function title_generation_callback( array $input ): array { } /** - * Permission check for the REST endpoint. + * Permission check for the title generation abilities endpoint. * * @since 0.1.0 * * @return bool */ public function title_generation_permission_callback(): bool { - return current_user_can( 'manage_options' ); + return current_user_can( 'manage_options' ); // TODO: this may be a tad aggressive, probably needs opened up to any user that has content creation permissions. } } From 16c2a9c980f16418622d5f818e83791e2c7276c4 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 13:30:34 -0600 Subject: [PATCH 09/29] Add an abstract Ability class that we can use to register abilities --- includes/Abstracts/Abstract_Ability.php | 114 ++++++++++++++++++ .../Exception/Invalid_Ability_Exception.php | 19 +++ 2 files changed, 133 insertions(+) create mode 100644 includes/Abstracts/Abstract_Ability.php create mode 100644 includes/Exception/Invalid_Ability_Exception.php diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php new file mode 100644 index 0000000..6badd78 --- /dev/null +++ b/includes/Abstracts/Abstract_Ability.php @@ -0,0 +1,114 @@ + $properties The properties of the ability. Must include `label`. + * + * @throws \WordPress\AI\Exception\Invalid_Ability_Exception Thrown if the label property is missing or invalid. + */ + public function __construct( string $name, array $properties = array() ) { + if ( ! isset( $properties['label'] ) || ! is_string( $properties['label'] ) ) { + throw new Invalid_Ability_Exception( esc_html__( 'The "label" property is required and must be a string.', 'ai' ) ); + } + + parent::__construct( + $name, + array( + 'label' => $properties['label'], + 'description' => $this->description(), + 'category' => $this->category(), + 'input_schema' => $this->input_schema(), + 'output_schema' => $this->output_schema(), + 'execute_callback' => array( $this, 'execute_callback' ), + 'permission_callback' => array( $this, 'permission_callback' ), + 'meta' => $this->meta(), + ) + ); + } + + /** + * Returns the description of the ability. + * + * @since 0.1.0 + * + * @return string The description of the ability. + */ + abstract protected function description(): string; + + /** + * Returns the category of the ability. + * + * @since 0.1.0 + * + * @return string The category of the ability. + */ + abstract protected function category(): string; + + /** + * Returns the input schema of the ability. + * + * @since 0.1.0 + * + * @return array The input schema of the ability. + */ + abstract protected function input_schema(): array; + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + abstract protected function output_schema(): array; + + /** + * Executes the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $args The input arguments to the ability. + * @return mixed|WP_Error The result of the ability execution, or a WP_Error on failure. + */ + abstract protected function execute_callback( $args ); + + /** + * Checks whether the current user has permission to execute the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $args The input arguments to the ability. + * @return bool|WP_Error True if the user has permission, false or WP_Error otherwise. + */ + abstract protected function permission_callback( $args ); + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + abstract protected function meta(): array; +} diff --git a/includes/Exception/Invalid_Ability_Exception.php b/includes/Exception/Invalid_Ability_Exception.php new file mode 100644 index 0000000..6b30b26 --- /dev/null +++ b/includes/Exception/Invalid_Ability_Exception.php @@ -0,0 +1,19 @@ + Date: Thu, 30 Oct 2025 13:59:29 -0600 Subject: [PATCH 10/29] Make some changes to the abstract ability class. Move all functionality for the title generation ability into the new ability class --- includes/Abilities/Title_Generation.php | 132 ++++++++++++++++++ includes/Abstracts/Abstract_Ability.php | 43 ++++-- .../Exception/Invalid_Ability_Exception.php | 19 --- .../Title_Generation/Title_Generation.php | 78 +---------- 4 files changed, 167 insertions(+), 105 deletions(-) create mode 100644 includes/Abilities/Title_Generation.php delete mode 100644 includes/Exception/Invalid_Ability_Exception.php diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php new file mode 100644 index 0000000..eb2edf6 --- /dev/null +++ b/includes/Abilities/Title_Generation.php @@ -0,0 +1,132 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => __( 'Content to generate title suggestions for.', 'ai' ), + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'titles' => array( + 'type' => 'array', + 'description' => __( 'Generated title suggestions.', 'ai' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $args The input arguments to the ability. + * @return mixed|WP_Error The result of the ability execution, or a WP_Error on failure. + */ + protected function execute_callback( mixed $input ) { + $args = wp_parse_args( + $input, + array( + 'content' => null, + ), + ); + + // TODO: Implement the title generation logic. + + return array( + 'feature_id' => $this->feature->get_id(), + 'label' => $this->feature->get_label(), + 'description' => $this->feature->get_description(), + 'enabled' => $this->feature->is_enabled(), + 'content' => $args['content'], + 'message' => __( 'Title generation feature is active', 'ai' ), + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.1.0 + * + * @param mixed $args The input arguments to the ability. + * @return bool|WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $args ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate titles.', 'ai' ) + ); + } + + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } +} diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index 6badd78..448f5d0 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -7,7 +7,7 @@ namespace WordPress\AI\Abstracts; -use WordPress\AI\Exception\Invalid_Ability_Exception; +use WordPress\AI\Abstracts\Abstract_Feature; use WP_Ability; /** @@ -17,6 +17,14 @@ */ abstract class Abstract_Ability extends WP_Ability { + /** + * The Feature class that the ability belongs to. + * + * @since 0.1.0 + * @var Abstract_Feature + */ + protected $feature; + /** * Constructor. * @@ -24,18 +32,14 @@ abstract class Abstract_Ability extends WP_Ability { * * @param string $name The name of the ability. * @param array $properties The properties of the ability. Must include `label`. - * - * @throws \WordPress\AI\Exception\Invalid_Ability_Exception Thrown if the label property is missing or invalid. */ public function __construct( string $name, array $properties = array() ) { - if ( ! isset( $properties['label'] ) || ! is_string( $properties['label'] ) ) { - throw new Invalid_Ability_Exception( esc_html__( 'The "label" property is required and must be a string.', 'ai' ) ); - } + $this->feature = $properties['feature'] ?? null; parent::__construct( $name, array( - 'label' => $properties['label'], + 'label' => $this->label(), 'description' => $this->description(), 'category' => $this->category(), 'input_schema' => $this->input_schema(), @@ -47,6 +51,17 @@ public function __construct( string $name, array $properties = array() ) { ); } + /** + * Returns the label of the ability. + * + * @since 0.1.0 + * + * @return string The label of the ability. + */ + protected function label(): string { + return $this->feature->get_label(); + } + /** * Returns the description of the ability. * @@ -54,7 +69,9 @@ public function __construct( string $name, array $properties = array() ) { * * @return string The description of the ability. */ - abstract protected function description(): string; + protected function description(): string { + return $this->feature->get_description(); + } /** * Returns the category of the ability. @@ -88,20 +105,20 @@ abstract protected function output_schema(): array; * * @since 0.1.0 * - * @param mixed $args The input arguments to the ability. + * @param mixed $input The input arguments to the ability. * @return mixed|WP_Error The result of the ability execution, or a WP_Error on failure. */ - abstract protected function execute_callback( $args ); + abstract protected function execute_callback( $input ); /** * Checks whether the current user has permission to execute the ability with the given input arguments. * * @since 0.1.0 * - * @param mixed $args The input arguments to the ability. - * @return bool|WP_Error True if the user has permission, false or WP_Error otherwise. + * @param mixed $input The input arguments to the ability. + * @return bool|WP_Error True if the user has permission, WP_Error otherwise. */ - abstract protected function permission_callback( $args ); + abstract protected function permission_callback( $input ); /** * Returns the meta of the ability. diff --git a/includes/Exception/Invalid_Ability_Exception.php b/includes/Exception/Invalid_Ability_Exception.php deleted file mode 100644 index 6b30b26..0000000 --- a/includes/Exception/Invalid_Ability_Exception.php +++ /dev/null @@ -1,19 +0,0 @@ - 'title-generation', 'label' => __( 'Title Generation', 'ai' ), - 'description' => __( 'Generates title suggestions from content.', 'ai' ), + 'description' => __( 'Generates title suggestions from content', 'ai' ), ); } @@ -69,79 +70,10 @@ public function register_abilities(): void { wp_register_ability( 'ai/title-generation', // TODO: add a method to build this slug from the feature ID. array( - 'label' => $this->get_label(), - 'description' => $this->get_description(), - 'category' => 'ai-experiments', // TODO: add a method to get the category slug. - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'content' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'Content to generate title suggestions for.', 'ai' ), - ), - ), - 'required' => array( - 'content', - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'titles' => array( - 'type' => 'array', - 'description' => __( 'Generated title suggestions.', 'ai' ), - 'items' => array( - 'type' => 'string', - ), - ), - ), - ), - 'execute_callback' => array( $this, 'title_generation_callback' ), - 'permission_callback' => array( $this, 'title_generation_permission_callback' ), - 'meta' => array( - 'show_in_rest' => true, - ), + 'label' => $this->get_label(), + 'feature' => $this, + 'ability_class' => Title_Generation_Ability::class, ), ); } - - /** - * Callback for the title generation abilities endpoint. - * - * @since 0.1.0 - * - * @param array $input The request data. - * @return array - */ - public function title_generation_callback( array $input ): array { - $args = wp_parse_args( - $input, - array( - 'content' => null, - ), - ); - - // TODO: Implement the title generation logic. - - return array( - 'feature_id' => $this->get_id(), - 'label' => $this->get_label(), - 'description' => $this->get_description(), - 'enabled' => $this->is_enabled(), - 'content' => $args['content'], - 'message' => __( 'Title generation feature is active!', 'ai' ), - ); - } - - /** - * Permission check for the title generation abilities endpoint. - * - * @since 0.1.0 - * - * @return bool - */ - public function title_generation_permission_callback(): bool { - return current_user_can( 'manage_options' ); // TODO: this may be a tad aggressive, probably needs opened up to any user that has content creation permissions. - } } From 70069f5f7136cb05c9d8131481505f32ff18a745 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:07:16 -0600 Subject: [PATCH 11/29] For now, register our single Abilities Category in the bootstrap --- includes/Abilities/Title_Generation.php | 2 +- .../Title_Generation/Title_Generation.php | 20 ------------------- includes/bootstrap.php | 13 ++++++++++++ 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index eb2edf6..74a4b2f 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -25,7 +25,7 @@ class Title_Generation extends Abstract_Ability { * @return string The category of the ability. */ protected function category(): string { - return 'ai-experiments'; // TODO: add a method to get the category slug? + return 'ai-experiments'; // TODO: add a reusable way to get the category slug? } /** diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 56d427f..c9c3af0 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -38,29 +38,9 @@ protected function load_feature_metadata(): array { * @since 0.1.0 */ public function register(): void { - add_action( 'wp_abilities_api_categories_init', array( $this, 'register_categories' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); } - /** - * Registers needed ability categories. - * - * TODO: If we want to use the same category for all abilities - * in this plugin, this should be moved out of this class into - * it's own category registration class. - * - * @since 0.1.0 - */ - public function register_categories(): void { - wp_register_ability_category( - 'ai-experiments', - array( - 'label' => __( 'AI Experiments', 'ai' ), - 'description' => __( 'Various AI experiment features.', 'ai' ), - ), - ); - } - /** * Registers any needed abilities. * diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 70e6537..71dff9f 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -172,6 +172,19 @@ function initialize_features(): void { $loader = new Feature_Loader( $registry ); $loader->register_default_features(); $loader->initialize_features(); + + add_action( + 'wp_abilities_api_categories_init', + static function () { + wp_register_ability_category( + 'ai-experiments', + array( + 'label' => __( 'AI Experiments', 'ai' ), + 'description' => __( 'Various AI experiment features.', 'ai' ), + ), + ); + } + ); } catch ( \Throwable $t ) { _doing_it_wrong( __NAMESPACE__ . '\initialize_features', From c34f992bdb2f0213bd9ae3e862200e1b2ee00564 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:10:05 -0600 Subject: [PATCH 12/29] Add a method to get the slug used when registering the ability --- includes/Abstracts/Abstract_Feature.php | 11 +++++++++++ includes/Contracts/Feature.php | 9 +++++++++ .../Features/Title_Generation/Title_Generation.php | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index 06eba4a..5a38442 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -129,6 +129,17 @@ public function get_description(): string { return $this->description; } + /** + * Gets the ability slug for the feature. + * + * @since 0.1.0 + * + * @return string The ability slug for the feature. + */ + public function get_ability_slug(): string { + return 'ai/' . $this->id; + } + /** * Checks if feature is enabled. * diff --git a/includes/Contracts/Feature.php b/includes/Contracts/Feature.php index 09ea9e0..f574bcd 100644 --- a/includes/Contracts/Feature.php +++ b/includes/Contracts/Feature.php @@ -48,6 +48,15 @@ public function get_label(): string; */ public function get_description(): string; + /** + * Gets the ability slug for the feature. + * + * @since 0.1.0 + * + * @return string The ability slug for the feature. + */ + public function get_ability_slug(): string; + /** * Registers the feature's hooks and functionality. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index c9c3af0..030c180 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -48,7 +48,7 @@ public function register(): void { */ public function register_abilities(): void { wp_register_ability( - 'ai/title-generation', // TODO: add a method to build this slug from the feature ID. + $this->get_ability_slug(), array( 'label' => $this->get_label(), 'feature' => $this, From 6594c5b2edcdc0e1facd1fb3ddcc225347256f83 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:13:05 -0600 Subject: [PATCH 13/29] Try changing permissions to see if that fixes our PR comment workflow again --- .github/workflows/pull-request-comments.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-comments.yml b/.github/workflows/pull-request-comments.yml index 051aa7a..b7b43b8 100644 --- a/.github/workflows/pull-request-comments.yml +++ b/.github/workflows/pull-request-comments.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/workflows/build-plugin-zip.yml permissions: contents: read - pull-requests: read + pull-requests: write # Leaves a comment on a pull request with a link to test the changes in a WordPress Playground instance. playground-details: From 5607fcec92e4ef90b65e3ad2250b9ce75385a686 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:29:14 -0600 Subject: [PATCH 14/29] Fix PHPCS and PHPStan errors --- includes/Abilities/Title_Generation.php | 10 +++++----- includes/Abstracts/Abstract_Ability.php | 7 +++---- phpstan.neon.dist | 8 +++----- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 74a4b2f..7252a8b 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -7,8 +7,8 @@ namespace WordPress\AI\Abilities; -use WordPress\AI\Abstracts\Abstract_Ability; use WP_Error; +use WordPress\AI\Abstracts\Abstract_Ability; /** * Title generation WordPress Ability. @@ -75,10 +75,10 @@ protected function output_schema(): array { * * @since 0.1.0 * - * @param mixed $args The input arguments to the ability. - * @return mixed|WP_Error The result of the ability execution, or a WP_Error on failure. + * @param mixed $input The input arguments to the ability. + * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. */ - protected function execute_callback( mixed $input ) { + protected function execute_callback( $input ) { $args = wp_parse_args( $input, array( @@ -104,7 +104,7 @@ protected function execute_callback( mixed $input ) { * @since 0.1.0 * * @param mixed $args The input arguments to the ability. - * @return bool|WP_Error True if the user has permission, WP_Error otherwise. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. */ protected function permission_callback( $args ) { if ( ! current_user_can( 'edit_posts' ) ) { diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index 448f5d0..cea97ee 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -7,7 +7,6 @@ namespace WordPress\AI\Abstracts; -use WordPress\AI\Abstracts\Abstract_Feature; use WP_Ability; /** @@ -21,7 +20,7 @@ abstract class Abstract_Ability extends WP_Ability { * The Feature class that the ability belongs to. * * @since 0.1.0 - * @var Abstract_Feature + * @var \WordPress\AI\Abstracts\Abstract_Feature */ protected $feature; @@ -106,7 +105,7 @@ abstract protected function output_schema(): array; * @since 0.1.0 * * @param mixed $input The input arguments to the ability. - * @return mixed|WP_Error The result of the ability execution, or a WP_Error on failure. + * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. */ abstract protected function execute_callback( $input ); @@ -116,7 +115,7 @@ abstract protected function execute_callback( $input ); * @since 0.1.0 * * @param mixed $input The input arguments to the ability. - * @return bool|WP_Error True if the user has permission, WP_Error otherwise. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. */ abstract protected function permission_callback( $input ); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7d9e38b..2691f55 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,13 +22,11 @@ parameters: paths: - ai.php - includes/ + - vendor/wordpress/abilities-api/includes/ excludePaths: analyse: - tests/ - - vendor/ + - vendor/**/* + - '!vendor/wordpress/abilities-api/**' analyseAndScan: - node_modules (?) - ignoreErrors: - # These functions exist in the WordPress Abilities API plugin, but are not yet defined in the WordPress core. - - '#Function wp_register_ability_category not found.#' - - '#Function wp_register_ability not found.#' From 9aea208b3e5352847e94ba1225587f3cff17de25 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:38:43 -0600 Subject: [PATCH 15/29] One more workflow permission change test --- .github/workflows/pull-request-comments.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-comments.yml b/.github/workflows/pull-request-comments.yml index b7b43b8..a8eac48 100644 --- a/.github/workflows/pull-request-comments.yml +++ b/.github/workflows/pull-request-comments.yml @@ -23,7 +23,7 @@ jobs: uses: ./.github/workflows/build-plugin-zip.yml permissions: contents: read - pull-requests: write + pull-requests: read # Leaves a comment on a pull request with a link to test the changes in a WordPress Playground instance. playground-details: @@ -31,8 +31,8 @@ jobs: runs-on: ubuntu-24.04 needs: build-plugin-zip permissions: + contents: write pull-requests: write - contents: read steps: - name: Download artifact From 79318d30441e62601bd21142180860d749da35c1 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 14:53:43 -0600 Subject: [PATCH 16/29] Revert workflow permission changes --- .github/workflows/pull-request-comments.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-comments.yml b/.github/workflows/pull-request-comments.yml index a8eac48..051aa7a 100644 --- a/.github/workflows/pull-request-comments.yml +++ b/.github/workflows/pull-request-comments.yml @@ -31,8 +31,8 @@ jobs: runs-on: ubuntu-24.04 needs: build-plugin-zip permissions: - contents: write pull-requests: write + contents: read steps: - name: Download artifact From dcec2f6484155406e831d522c121516d0ff7403c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 30 Oct 2025 15:04:54 -0600 Subject: [PATCH 17/29] Add arguments to allow passing in a post ID, which if it matches a post we will use the content from that. And a number from 1 to 10 to control how many titles we generate --- includes/Abilities/Title_Generation.php | 40 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 7252a8b..ed190ee 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -42,7 +42,19 @@ protected function input_schema(): array { 'content' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', - 'description' => __( 'Content to generate title suggestions for.', 'ai' ), + 'description' => esc_html__( 'Content to generate title suggestions for.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Content from this post will be used to generate title suggestions. This overrides the content parameter if both are provided.', 'ai' ), + ), + 'n' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 10, + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Number of titles to generate', 'ai' ), ), ), ); @@ -61,7 +73,7 @@ protected function output_schema(): array { 'properties' => array( 'titles' => array( 'type' => 'array', - 'description' => __( 'Generated title suggestions.', 'ai' ), + 'description' => esc_html__( 'Generated title suggestions.', 'ai' ), 'items' => array( 'type' => 'string', ), @@ -79,13 +91,31 @@ protected function output_schema(): array { * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. */ protected function execute_callback( $input ) { + // Default arguments. $args = wp_parse_args( $input, array( 'content' => null, + 'post_id' => null, + 'n' => 1, ), ); + // If a post ID is provided, ensure the post exists before using it's content. + if ( $args['post_id'] ) { + $post = get_post( $args['post_id'] ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + $args['content'] = $post->post_content; + } + // TODO: Implement the title generation logic. return array( @@ -93,8 +123,10 @@ protected function execute_callback( $input ) { 'label' => $this->feature->get_label(), 'description' => $this->feature->get_description(), 'enabled' => $this->feature->is_enabled(), - 'content' => $args['content'], - 'message' => __( 'Title generation feature is active', 'ai' ), + 'content' => wp_kses_post( $args['content'] ), + 'post_id' => absint( $args['post_id'] ) ?? esc_html__( 'Not provided', 'ai' ), + 'n' => absint( $args['n'] ), + 'message' => esc_html__( 'Title generation feature is active', 'ai' ), ); } From a574ccbb3ad79d4ddc47ce09026b64370ce0c823 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 12:00:46 -0600 Subject: [PATCH 18/29] Ensure we have content to process before we generate titles --- includes/Abilities/Title_Generation.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index ed190ee..199c1a4 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -101,7 +101,7 @@ protected function execute_callback( $input ) { ), ); - // If a post ID is provided, ensure the post exists before using it's content. + // If a post ID is provided, ensure the post exists before using its' content. if ( $args['post_id'] ) { $post = get_post( $args['post_id'] ); @@ -116,6 +116,14 @@ protected function execute_callback( $input ) { $args['content'] = $post->post_content; } + // If we have no content, return an error. + if ( ! $args['content'] ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate title suggestions.', 'ai' ) + ); + } + // TODO: Implement the title generation logic. return array( From 88eb392393b69dff8f565351fb889625cc64cb32 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 12:00:56 -0600 Subject: [PATCH 19/29] Add unit tests to cover new classes --- .../Abilities/Title_GenerationTest.php | 372 ++++++++++++++++++ .../Abstracts/Abstract_AbilityTest.php | 244 ++++++++++++ .../Example_Feature/Example_FeatureTest.php | 14 + .../Title_Generation/Title_GenerationTest.php | 14 + tests/bootstrap.php | 12 +- 5 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Includes/Abilities/Title_GenerationTest.php create mode 100644 tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php new file mode 100644 index 0000000..42974ec --- /dev/null +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -0,0 +1,372 @@ + 'title-generation', + 'label' => 'Title Generation', + 'description' => 'Generates title suggestions from content', + ); + } + + /** + * Registers the feature. + * + * @since 0.1.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Title_Generation Ability test case. + * + * @since 0.1.0 + */ +class Title_GenerationTest extends WP_UnitTestCase { + + /** + * Title_Generation ability instance. + * + * @var Title_Generation + */ + private $ability; + + /** + * Test feature instance. + * + * @var Test_Title_Generation_Feature + */ + private $feature; + + /** + * Set up test case. + * + * @since 0.1.0 + */ + public function setUp(): void { + parent::setUp(); + + $this->feature = new Test_Title_Generation_Feature(); + $this->ability = new Title_Generation( + 'ai/title-generation', + array( 'feature' => $this->feature ) + ); + } + + /** + * Tear down test case. + * + * @since 0.1.0 + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since 0.1.0 + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since 0.1.0 + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'post_id', $schema['properties'], 'Schema should have post_id property' ); + $this->assertArrayHasKey( 'n', $schema['properties'], 'Schema should have n property' ); + + // Verify content property. + $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' ); + + // Verify post_id property. + $this->assertEquals( 'integer', $schema['properties']['post_id']['type'], 'Post ID should be integer type' ); + $this->assertEquals( 'absint', $schema['properties']['post_id']['sanitize_callback'], 'Post ID should use absint' ); + + // Verify n property. + $this->assertEquals( 'integer', $schema['properties']['n']['type'], 'n should be integer type' ); + $this->assertEquals( 1, $schema['properties']['n']['minimum'], 'n minimum should be 1' ); + $this->assertEquals( 10, $schema['properties']['n']['maximum'], 'n maximum should be 10' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since 0.1.0 + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'titles', $schema['properties'], 'Schema should have titles property' ); + $this->assertEquals( 'array', $schema['properties']['titles']['type'], 'Titles should be array type' ); + $this->assertArrayHasKey( 'items', $schema['properties']['titles'], 'Titles should have items' ); + $this->assertEquals( 'string', $schema['properties']['titles']['items']['type'], 'Title items should be string type' ); + } + + /** + * Test that execute_callback() handles content parameter correctly. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This is some test content.', + 'n' => 3, + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 'title-generation', $result['feature_id'], 'Feature ID should match' ); + $this->assertEquals( 'Title Generation', $result['label'], 'Label should match' ); + $this->assertEquals( 'Generates title suggestions from content', $result['description'], 'Description should match' ); + $this->assertEquals( 'This is some test content.', $result['content'], 'Content should match input' ); + $this->assertEquals( 3, $result['n'], 'n should match input' ); + } + + /** + * Test that execute_callback() handles post_id parameter correctly. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'This is post content.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'post_id' => $post_id, + 'n' => 2, + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 'This is post content.', $result['content'], 'Content should come from post' ); + $this->assertEquals( $post_id, $result['post_id'], 'Post ID should match' ); + } + + /** + * Test that execute_callback() returns error when post_id points to non-existent post. + * + * @since 0.1.0 + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'post_id' => 99999, // Non-existent post ID. + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since 0.1.0 + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() uses default values. + * + * @since 0.1.0 + */ + public function test_execute_callback_uses_defaults() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'Test content', + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 1, $result['n'], 'n should default to 1' ); + } + + /** + * Test that execute_callback() prioritizes post_id over content. + * + * @since 0.1.0 + */ + public function test_execute_callback_post_id_overrides_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Post content takes priority.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'content' => 'This content should be ignored.', + 'post_id' => $post_id, + ); + $result = $method->invoke( $this->ability, $input ); + + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertEquals( 'Post content takes priority.', $result['content'], 'Post content should override provided content' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a user with edit_posts capability. + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since 0.1.0 + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Create a user without edit_posts capability. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since 0.1.0 + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Ensure no user is logged in. + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since 0.1.0 + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } +} + diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php new file mode 100644 index 0000000..29e9990 --- /dev/null +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -0,0 +1,244 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'test_input' => array( + 'type' => 'string', + ), + ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'test_output' => array( + 'type' => 'string', + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return mixed|\WP_Error The result of the ability execution, or a WP_Error on failure. + */ + protected function execute_callback( $input ) { + return array( 'result' => 'test' ); + } + + /** + * Checks whether the current user has permission to execute the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + protected function permission_callback( $input ) { + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( 'test' => 'meta' ); + } + + /** + * Get feature for testing. + * + * @since 0.1.0 + * + * @return Abstract_Feature|null Feature instance. + */ + public function get_feature() { + return $this->feature; + } +} + +/** + * Test feature for Abstract_Ability tests. + * + * @since 0.1.0 + */ +class Test_Ability_Feature extends Abstract_Feature { + /** + * Loads feature metadata. + * + * @since 0.1.0 + * + * @return array{id: string, label: string, description: string} Feature metadata. + */ + protected function load_feature_metadata(): array { + return array( + 'id' => 'test-ability-feature', + 'label' => 'Test Ability Feature', + 'description' => 'A test feature for ability testing', + ); + } + + /** + * Registers the feature. + * + * @since 0.1.0 + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Abstract_Ability test case. + * + * @since 0.1.0 + */ +class Abstract_AbilityTest extends WP_UnitTestCase { + + /** + * Test that constructor properly sets up the ability. + * + * @since 0.1.0 + */ + public function test_constructor_sets_up_ability() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( 'feature' => $feature ) + ); + + $this->assertSame( $feature, $ability->get_feature(), 'Feature should be stored in ability' ); + } + + /** + * Test that constructor calls parent constructor with correct properties. + * + * @since 0.1.0 + */ + public function test_constructor_calls_parent_with_properties() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( 'feature' => $feature ) + ); + + // Verify the ability was registered with WordPress Abilities API. + // We can't directly test parent::__construct, but we can verify the ability exists. + $this->assertInstanceOf( Abstract_Ability::class, $ability, 'Ability should be instance of Abstract_Ability' ); + } + + /** + * Test that label() delegates to feature's get_label(). + * + * @since 0.1.0 + */ + public function test_label_delegates_to_feature() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( 'feature' => $feature ) + ); + + // Use reflection to test protected method. + $reflection = new \ReflectionClass( $ability ); + $method = $reflection->getMethod( 'label' ); + $method->setAccessible( true ); + + $result = $method->invoke( $ability ); + + $this->assertEquals( $feature->get_label(), $result, 'Label should match feature label' ); + $this->assertEquals( 'Test Ability Feature', $result, 'Label should be correct' ); + } + + /** + * Test that description() delegates to feature's get_description(). + * + * @since 0.1.0 + */ + public function test_description_delegates_to_feature() { + $feature = new Test_Ability_Feature(); + $ability = new Test_Ability( + 'test-ability', + array( 'feature' => $feature ) + ); + + // Use reflection to test protected method. + $reflection = new \ReflectionClass( $ability ); + $method = $reflection->getMethod( 'description' ); + $method->setAccessible( true ); + + $result = $method->invoke( $ability ); + + $this->assertEquals( $feature->get_description(), $result, 'Description should match feature description' ); + $this->assertEquals( 'A test feature for ability testing', $result, 'Description should be correct' ); + } + + /** + * Test that constructor requires feature (feature is required for label/description). + * + * @since 0.1.0 + */ + public function test_constructor_requires_feature() { + $this->expectException( \Error::class ); + $this->expectExceptionMessage( 'Call to a member function get_label() on null' ); + + // Attempting to construct without a feature should fail because + // label() and description() methods require the feature. + new Test_Ability( 'test-ability', array() ); + } +} + diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php index 4d0db0c..8befbd1 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php @@ -57,6 +57,20 @@ public function test_feature_registration() { $this->assertTrue( $feature->is_enabled() ); } + /** + * Test that get_ability_slug() returns the correct slug format. + * + * @since 0.1.0 + */ + public function test_get_ability_slug_returns_correct_format() { + $feature = new Example_Feature(); + + $slug = $feature->get_ability_slug(); + + $this->assertEquals( 'ai/example-feature', $slug, 'Ability slug should be prefixed with ai/' ); + $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); + } + /** * Test that footer content is added for logged-in users. * diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index 7e40f39..1a2d7e9 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -56,4 +56,18 @@ public function test_feature_registration() { $this->assertEquals( 'Title Generation', $feature->get_label() ); $this->assertTrue( $feature->is_enabled() ); } + + /** + * Test that get_ability_slug() returns the correct slug format. + * + * @since 0.1.0 + */ + public function test_get_ability_slug_returns_correct_format() { + $feature = new Title_Generation(); + + $slug = $feature->get_ability_slug(); + + $this->assertEquals( 'ai/title-generation', $slug, 'Ability slug should be prefixed with ai/' ); + $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 032629b..21dbbe7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,11 +7,21 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); +// Load Abilities API classes before autoloader to ensure WP_Ability class is available. +if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { + require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; +} + // Load Composer dependencies if applicable. if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/autoload.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/autoload.php'; } +// Load Abilities API bootstrap for functions. +if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { + require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php'; +} + // Detect where to load the WordPress tests environment from. if ( false !== getenv( 'WP_TESTS_DIR' ) ) { $_test_root = getenv( 'WP_TESTS_DIR' ); @@ -37,4 +47,4 @@ static function (): void { ); // Start up the WP testing environment. -require $_test_root . '/includes/bootstrap.php'; \ No newline at end of file +require $_test_root . '/includes/bootstrap.php'; From e27380266b0135a0895b89493423c25ff4b9cab7 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 12:24:08 -0600 Subject: [PATCH 20/29] Fix tests on trunk --- tests/bootstrap.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 21dbbe7..e4d07a7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,7 +8,8 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); // Load Abilities API classes before autoloader to ensure WP_Ability class is available. -if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { +// Only load if not already present (e.g., when running against WordPress trunk with Abilities API merged). +if ( ! class_exists( 'WP_Ability' ) && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; } @@ -18,7 +19,8 @@ } // Load Abilities API bootstrap for functions. -if ( file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { +// Only load if WP_Ability is not already available (e.g., when running against WordPress trunk). +if ( ! class_exists( 'WP_Ability' ) && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php'; } From 1beac35690b2b687d343748db530cd782692f9c3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 31 Oct 2025 12:34:56 -0600 Subject: [PATCH 21/29] Actually fix tests on trunk --- tests/bootstrap.php | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e4d07a7..352b49e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,9 +7,34 @@ define( 'TESTS_REPO_ROOT_DIR', dirname( __DIR__ ) ); +/** + * Check if WordPress core has the Abilities API (e.g., in trunk). + * + * @return bool True if WordPress core includes Abilities API, false otherwise. + */ +function wp_ai_has_core_abilities_api(): bool { + // Check common WordPress core locations for the Abilities API file. + $possible_paths = array( + // wp-env location + '/var/www/html/wp-includes/abilities-api/class-wp-ability.php', + // Relative to tests directory (typical WordPress test setup) + TESTS_REPO_ROOT_DIR . '/../../../../wp-includes/abilities-api/class-wp-ability.php', + // Relative to plugin directory (alternative test setup) + TESTS_REPO_ROOT_DIR . '/../../../../../wp-includes/abilities-api/class-wp-ability.php', + ); + + foreach ( $possible_paths as $path ) { + if ( file_exists( $path ) ) { + return true; + } + } + + return false; +} + // Load Abilities API classes before autoloader to ensure WP_Ability class is available. -// Only load if not already present (e.g., when running against WordPress trunk with Abilities API merged). -if ( ! class_exists( 'WP_Ability' ) && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { +// Only load from vendor if WordPress core doesn't already include it (e.g., when running against trunk). +if ( ! wp_ai_has_core_abilities_api() && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/abilities-api/class-wp-ability.php'; } @@ -19,8 +44,8 @@ } // Load Abilities API bootstrap for functions. -// Only load if WP_Ability is not already available (e.g., when running against WordPress trunk). -if ( ! class_exists( 'WP_Ability' ) && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { +// Only load from vendor if WordPress core doesn't already include it. +if ( ! wp_ai_has_core_abilities_api() && file_exists( TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php' ) ) { require_once TESTS_REPO_ROOT_DIR . '/vendor/wordpress/abilities-api/includes/bootstrap.php'; } From 779d3d6c711ef61f134617ae84bacaf651cc86f8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 15:43:11 -0700 Subject: [PATCH 22/29] Remove the get_ability_slug method and instead directly set the Ability name when registering the Ability --- includes/Abstracts/Abstract_Feature.php | 11 ----------- includes/Contracts/Feature.php | 9 --------- .../Features/Title_Generation/Title_Generation.php | 2 +- .../Example_Feature/Example_FeatureTest.php | 14 -------------- .../Title_Generation/Title_GenerationTest.php | 14 -------------- 5 files changed, 1 insertion(+), 49 deletions(-) diff --git a/includes/Abstracts/Abstract_Feature.php b/includes/Abstracts/Abstract_Feature.php index 5a38442..06eba4a 100644 --- a/includes/Abstracts/Abstract_Feature.php +++ b/includes/Abstracts/Abstract_Feature.php @@ -129,17 +129,6 @@ public function get_description(): string { return $this->description; } - /** - * Gets the ability slug for the feature. - * - * @since 0.1.0 - * - * @return string The ability slug for the feature. - */ - public function get_ability_slug(): string { - return 'ai/' . $this->id; - } - /** * Checks if feature is enabled. * diff --git a/includes/Contracts/Feature.php b/includes/Contracts/Feature.php index f574bcd..09ea9e0 100644 --- a/includes/Contracts/Feature.php +++ b/includes/Contracts/Feature.php @@ -48,15 +48,6 @@ public function get_label(): string; */ public function get_description(): string; - /** - * Gets the ability slug for the feature. - * - * @since 0.1.0 - * - * @return string The ability slug for the feature. - */ - public function get_ability_slug(): string; - /** * Registers the feature's hooks and functionality. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 030c180..dc86c34 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -48,7 +48,7 @@ public function register(): void { */ public function register_abilities(): void { wp_register_ability( - $this->get_ability_slug(), + 'ai/' . $this->get_id(), array( 'label' => $this->get_label(), 'feature' => $this, diff --git a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php index 8befbd1..4d0db0c 100644 --- a/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php +++ b/tests/Integration/Includes/Features/Example_Feature/Example_FeatureTest.php @@ -57,20 +57,6 @@ public function test_feature_registration() { $this->assertTrue( $feature->is_enabled() ); } - /** - * Test that get_ability_slug() returns the correct slug format. - * - * @since 0.1.0 - */ - public function test_get_ability_slug_returns_correct_format() { - $feature = new Example_Feature(); - - $slug = $feature->get_ability_slug(); - - $this->assertEquals( 'ai/example-feature', $slug, 'Ability slug should be prefixed with ai/' ); - $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); - } - /** * Test that footer content is added for logged-in users. * diff --git a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php index 1a2d7e9..7e40f39 100644 --- a/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php +++ b/tests/Integration/Includes/Features/Title_Generation/Title_GenerationTest.php @@ -56,18 +56,4 @@ public function test_feature_registration() { $this->assertEquals( 'Title Generation', $feature->get_label() ); $this->assertTrue( $feature->is_enabled() ); } - - /** - * Test that get_ability_slug() returns the correct slug format. - * - * @since 0.1.0 - */ - public function test_get_ability_slug_returns_correct_format() { - $feature = new Title_Generation(); - - $slug = $feature->get_ability_slug(); - - $this->assertEquals( 'ai/title-generation', $slug, 'Ability slug should be prefixed with ai/' ); - $this->assertStringStartsWith( 'ai/', $slug, 'Ability slug should start with ai/' ); - } } From fff6c5d1148598817613ece770df71b0849d22a0 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 15:45:21 -0700 Subject: [PATCH 23/29] Reference the Feature classes themselves when registering our default Features --- includes/Feature_Loader.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Feature_Loader.php b/includes/Feature_Loader.php index aba8cf0..1ee336e 100644 --- a/includes/Feature_Loader.php +++ b/includes/Feature_Loader.php @@ -102,8 +102,8 @@ public function register_default_features(): void { */ private function get_default_features(): array { $feature_classes = array( - 'WordPress\AI\Features\Example_Feature\Example_Feature', - 'WordPress\AI\Features\Title_Generation\Title_Generation', + \WordPress\AI\Features\Example_Feature\Example_Feature::class, + \WordPress\AI\Features\Title_Generation\Title_Generation::class, ); /** From 550b4fb7e605f153e0a5a58a7d926adc7a575af3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 16:04:35 -0700 Subject: [PATCH 24/29] Remove the coupling of a Feature to an Ability and instead pass in the Feature label and description when we register the Ability --- includes/Abstracts/Abstract_Ability.php | 36 ++----------------- .../Title_Generation/Title_Generation.php | 2 +- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index cea97ee..1140977 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -16,14 +16,6 @@ */ abstract class Abstract_Ability extends WP_Ability { - /** - * The Feature class that the ability belongs to. - * - * @since 0.1.0 - * @var \WordPress\AI\Abstracts\Abstract_Feature - */ - protected $feature; - /** * Constructor. * @@ -33,13 +25,11 @@ abstract class Abstract_Ability extends WP_Ability { * @param array $properties The properties of the ability. Must include `label`. */ public function __construct( string $name, array $properties = array() ) { - $this->feature = $properties['feature'] ?? null; - parent::__construct( $name, array( - 'label' => $this->label(), - 'description' => $this->description(), + 'label' => $properties['label'] ?? '', + 'description' => $properties['description'] ?? '', 'category' => $this->category(), 'input_schema' => $this->input_schema(), 'output_schema' => $this->output_schema(), @@ -50,28 +40,6 @@ public function __construct( string $name, array $properties = array() ) { ); } - /** - * Returns the label of the ability. - * - * @since 0.1.0 - * - * @return string The label of the ability. - */ - protected function label(): string { - return $this->feature->get_label(); - } - - /** - * Returns the description of the ability. - * - * @since 0.1.0 - * - * @return string The description of the ability. - */ - protected function description(): string { - return $this->feature->get_description(); - } - /** * Returns the category of the ability. * diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index dc86c34..2cc480b 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -51,7 +51,7 @@ public function register_abilities(): void { 'ai/' . $this->get_id(), array( 'label' => $this->get_label(), - 'feature' => $this, + 'description' => $this->get_description(), 'ability_class' => Title_Generation_Ability::class, ), ); From 032a3e7ffc0b9fc6cbdebb8280f31dce153b0b9c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 16:06:48 -0700 Subject: [PATCH 25/29] Add comment documenting why we are registering a generic Abilities category --- includes/bootstrap.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 71dff9f..bcaf62e 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -176,6 +176,11 @@ function initialize_features(): void { add_action( 'wp_abilities_api_categories_init', static function () { + /** + * Register a generic catch-all category that all + * Abilities we register can use. Can re-evaluate this + * in the future if we need/want more specific categories. + */ wp_register_ability_category( 'ai-experiments', array( From 381e2b7349a66ce1b9b156b14519feeb42049280 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 16:22:21 -0700 Subject: [PATCH 26/29] Fix tests --- includes/Abilities/Title_Generation.php | 8 ++-- .../Abilities/Title_GenerationTest.php | 7 ++- .../Abstracts/Abstract_AbilityTest.php | 48 +++++++++---------- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 199c1a4..529cf8e 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -127,14 +127,12 @@ protected function execute_callback( $input ) { // TODO: Implement the title generation logic. return array( - 'feature_id' => $this->feature->get_id(), - 'label' => $this->feature->get_label(), - 'description' => $this->feature->get_description(), - 'enabled' => $this->feature->is_enabled(), + 'name' => $this->get_name(), + 'label' => $this->get_label(), + 'description' => $this->get_description(), 'content' => wp_kses_post( $args['content'] ), 'post_id' => absint( $args['post_id'] ) ?? esc_html__( 'Not provided', 'ai' ), 'n' => absint( $args['n'] ), - 'message' => esc_html__( 'Title generation feature is active', 'ai' ), ); } diff --git a/tests/Integration/Includes/Abilities/Title_GenerationTest.php b/tests/Integration/Includes/Abilities/Title_GenerationTest.php index 42974ec..7af717b 100644 --- a/tests/Integration/Includes/Abilities/Title_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Title_GenerationTest.php @@ -75,7 +75,10 @@ public function setUp(): void { $this->feature = new Test_Title_Generation_Feature(); $this->ability = new Title_Generation( 'ai/title-generation', - array( 'feature' => $this->feature ) + array( + 'label' => $this->feature->get_label(), + 'description' => $this->feature->get_description(), + ) ); } @@ -175,7 +178,7 @@ public function test_execute_callback_with_content() { $result = $method->invoke( $this->ability, $input ); $this->assertIsArray( $result, 'Result should be an array' ); - $this->assertEquals( 'title-generation', $result['feature_id'], 'Feature ID should match' ); + $this->assertEquals( 'ai/title-generation', $result['name'], 'Feature name should match' ); $this->assertEquals( 'Title Generation', $result['label'], 'Label should match' ); $this->assertEquals( 'Generates title suggestions from content', $result['description'], 'Description should match' ); $this->assertEquals( 'This is some test content.', $result['content'], 'Content should match input' ); diff --git a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php index 29e9990..913d993 100644 --- a/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php +++ b/tests/Integration/Includes/Abstracts/Abstract_AbilityTest.php @@ -98,17 +98,6 @@ protected function permission_callback( $input ) { protected function meta(): array { return array( 'test' => 'meta' ); } - - /** - * Get feature for testing. - * - * @since 0.1.0 - * - * @return Abstract_Feature|null Feature instance. - */ - public function get_feature() { - return $this->feature; - } } /** @@ -158,10 +147,13 @@ public function test_constructor_sets_up_ability() { $feature = new Test_Ability_Feature(); $ability = new Test_Ability( 'test-ability', - array( 'feature' => $feature ) + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) ); - $this->assertSame( $feature, $ability->get_feature(), 'Feature should be stored in ability' ); + $this->assertSame( $feature->get_label(), $ability->get_label(), 'Label should be stored in ability' ); } /** @@ -173,7 +165,10 @@ public function test_constructor_calls_parent_with_properties() { $feature = new Test_Ability_Feature(); $ability = new Test_Ability( 'test-ability', - array( 'feature' => $feature ) + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) ); // Verify the ability was registered with WordPress Abilities API. @@ -190,12 +185,15 @@ public function test_label_delegates_to_feature() { $feature = new Test_Ability_Feature(); $ability = new Test_Ability( 'test-ability', - array( 'feature' => $feature ) + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) ); // Use reflection to test protected method. $reflection = new \ReflectionClass( $ability ); - $method = $reflection->getMethod( 'label' ); + $method = $reflection->getMethod( 'get_label' ); $method->setAccessible( true ); $result = $method->invoke( $ability ); @@ -213,12 +211,15 @@ public function test_description_delegates_to_feature() { $feature = new Test_Ability_Feature(); $ability = new Test_Ability( 'test-ability', - array( 'feature' => $feature ) + array( + 'label' => $feature->get_label(), + 'description' => $feature->get_description(), + ) ); // Use reflection to test protected method. $reflection = new \ReflectionClass( $ability ); - $method = $reflection->getMethod( 'description' ); + $method = $reflection->getMethod( 'get_description' ); $method->setAccessible( true ); $result = $method->invoke( $ability ); @@ -228,16 +229,15 @@ public function test_description_delegates_to_feature() { } /** - * Test that constructor requires feature (feature is required for label/description). + * Test that constructor requires label. * * @since 0.1.0 */ - public function test_constructor_requires_feature() { - $this->expectException( \Error::class ); - $this->expectExceptionMessage( 'Call to a member function get_label() on null' ); + public function test_constructor_requires_label() { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability properties must contain a `label` string.' ); - // Attempting to construct without a feature should fail because - // label() and description() methods require the feature. + // Attempting to construct without a label should fail because. new Test_Ability( 'test-ability', array() ); } } From 86f1ebe9f2733d6f0ca2cdab571309680cdb8666 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 10 Nov 2025 16:26:38 -0700 Subject: [PATCH 27/29] Declare strict inline types to new files, following changes made in #72 --- includes/Abilities/Title_Generation.php | 2 ++ includes/Abstracts/Abstract_Ability.php | 2 ++ includes/Features/Title_Generation/Title_Generation.php | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 529cf8e..3fc7361 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -5,6 +5,8 @@ * @package WordPress\AI */ +declare( strict_types=1 ); + namespace WordPress\AI\Abilities; use WP_Error; diff --git a/includes/Abstracts/Abstract_Ability.php b/includes/Abstracts/Abstract_Ability.php index 1140977..8413c25 100644 --- a/includes/Abstracts/Abstract_Ability.php +++ b/includes/Abstracts/Abstract_Ability.php @@ -5,6 +5,8 @@ * @package WordPress\AI\Abstracts */ +declare( strict_types=1 ); + namespace WordPress\AI\Abstracts; use WP_Ability; diff --git a/includes/Features/Title_Generation/Title_Generation.php b/includes/Features/Title_Generation/Title_Generation.php index 2cc480b..efeb9a6 100644 --- a/includes/Features/Title_Generation/Title_Generation.php +++ b/includes/Features/Title_Generation/Title_Generation.php @@ -5,6 +5,8 @@ * @package WordPress\AI */ +declare( strict_types=1 ); + namespace WordPress\AI\Features\Title_Generation; use WordPress\AI\Abilities\Title_Generation as Title_Generation_Ability; @@ -18,7 +20,7 @@ class Title_Generation extends Abstract_Feature { /** - * Load feature metadata. + * {@inheritDoc} * * @since 0.1.0 * @@ -33,7 +35,7 @@ protected function load_feature_metadata(): array { } /** - * Register any needed hooks. + * {@inheritDoc} * * @since 0.1.0 */ From e0e4873fb33f4fd412b5dbfc9f5a97c1f0fc85c9 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 11 Nov 2025 09:54:21 -0700 Subject: [PATCH 28/29] Add more robust permission checks. Better check on post ID before returning it --- includes/Abilities/Title_Generation.php | 39 +++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index 3fc7361..cda1af4 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -133,7 +133,7 @@ protected function execute_callback( $input ) { 'label' => $this->get_label(), 'description' => $this->get_description(), 'content' => wp_kses_post( $args['content'] ), - 'post_id' => absint( $args['post_id'] ) ?? esc_html__( 'Not provided', 'ai' ), + 'post_id' => $args['post_id'] ? absint( $args['post_id'] ) : esc_html__( 'Not provided', 'ai' ), 'n' => absint( $args['n'] ), ); } @@ -147,7 +147,42 @@ protected function execute_callback( $input ) { * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. */ protected function permission_callback( $args ) { - if ( ! current_user_can( 'edit_posts' ) ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null; + + if ( $post_id ) { + $post = get_post( $args['post_id'] ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) ) + ); + } + + // Ensure the user has permission to edit this particular post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate titles for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. return new WP_Error( 'insufficient_capabilities', esc_html__( 'You do not have permission to generate titles.', 'ai' ) From 9b9b12345b20a9cf6d44395c1640317756afacf2 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 11 Nov 2025 16:25:19 -0700 Subject: [PATCH 29/29] When checking permissions when a specific post ID is provided, check the read capability instead of edit capability --- includes/Abilities/Title_Generation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Title_Generation.php b/includes/Abilities/Title_Generation.php index cda1af4..750dffb 100644 --- a/includes/Abilities/Title_Generation.php +++ b/includes/Abilities/Title_Generation.php @@ -161,8 +161,8 @@ protected function permission_callback( $args ) { ); } - // Ensure the user has permission to edit this particular post. - if ( ! current_user_can( 'edit_post', $post_id ) ) { + // Ensure the user has permission to read this particular post. + if ( ! current_user_can( 'read_post', $post_id ) ) { return new WP_Error( 'insufficient_capabilities', esc_html__( 'You do not have permission to generate titles for this post.', 'ai' )