From b3e4f7ae0a7583b2a8924b120b12169a3d78e88c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 9 Jun 2026 17:55:53 +0100 Subject: [PATCH 1/9] Add a core/settings ability Add the core/settings ability, matching the equivalent WordPress core change. Read-only; returns settings flagged show_in_abilities as a flat name => value map, filterable by group or by name, gated on manage_options. The Settings class is kept near-identical to core's WP_Settings_Abilities and registers at priority 11, unregistering any core-provided copy first so the plugin wins when both are present. A generic Show_In_Abilities component seeds the show_in_abilities flag onto a curated set of core settings so the ability returns data on a stock site. Includes a Playwright e2e spec that drives the ability through the @wordpress/abilities client. Keeps the output schema free of unsupported formats and per-property defaults so the client-side validator accepts it and does not inject defaults into filtered results. --- includes/Abilities/Settings/Settings.php | 328 ++++++++++++++++++ includes/Abilities/Show_In_Abilities.php | 104 ++++++ includes/Main.php | 7 + .../Abilities/Settings/SettingsTest.php | 254 ++++++++++++++ .../Abilities/Show_In_AbilitiesTest.php | 131 +++++++ .../e2e/specs/abilities/core-settings.spec.js | 106 ++++++ 6 files changed, 930 insertions(+) create mode 100644 includes/Abilities/Settings/Settings.php create mode 100644 includes/Abilities/Show_In_Abilities.php create mode 100644 tests/Integration/Includes/Abilities/Settings/SettingsTest.php create mode 100644 tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php create mode 100644 tests/e2e/specs/abilities/core-settings.spec.js diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php new file mode 100644 index 000000000..e90ee932f --- /dev/null +++ b/includes/Abilities/Settings/Settings.php @@ -0,0 +1,328 @@ + esc_html__( 'Site', 'ai' ), + 'description' => esc_html__( 'Abilities that retrieve or modify site information and settings.', 'ai' ), + ) + ); + } + + /** + * Registers all settings abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since 1.1.0 + */ + public static function register(): void { + self::register_get_settings(); + + /* + * A future write-oriented ability can be registered here, reusing the shared + * helpers below (get_exposed_settings(), value_schema(), cast_value()): + * + * self::register_manage_settings(); + */ + } + + /** + * Registers the read-only `core/settings` ability. + * + * @since 1.1.0 + */ + public static function register_get_settings(): void { + // Plugin: unregister any core-provided copy first so the plugin's version wins. + if ( wp_has_ability( 'core/settings' ) ) { + wp_unregister_ability( 'core/settings' ); + } + + $settings = self::get_exposed_settings(); + $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); + $slugs = array_keys( $settings ); + $properties = array(); + foreach ( $settings as $exposed_name => $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + } + + wp_register_ability( + 'core/settings', + array( + 'label' => esc_html__( 'Get Settings', 'ai' ), + 'description' => esc_html__( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group or by setting name.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_settings_input_schema( $groups, $slugs ), + 'output_schema' => array( + 'type' => 'object', + 'description' => esc_html__( 'A map of setting name to its current value.', 'ai' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( self::class, 'execute_get_settings' ), + 'permission_callback' => array( self::class, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Executes the `core/settings` ability. + * + * @since 1.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array Map of exposed setting name to current value. + */ + public static function execute_get_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = self::get_exposed_settings(); + $group = isset( $input['group'] ) ? (string) $input['group'] : ''; + $slugs = isset( $input['slugs'] ) && is_array( $input['slugs'] ) ? $input['slugs'] : array(); + + $result = array(); + foreach ( $settings as $exposed_name => $setting ) { + if ( '' !== $group && $setting['group'] !== $group ) { + continue; + } + if ( ! empty( $slugs ) && ! in_array( $exposed_name, $slugs, true ) ) { + continue; + } + + $type = isset( $setting['schema']['type'] ) ? (string) $setting['schema']['type'] : 'string'; + $value = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = self::cast_value( $value, $type ); + } + + return $result; + } + + /** + * Checks whether the current user may use the settings abilities. + * + * @since 1.1.0 + * + * @return bool True if the current user can manage options. + */ + public static function has_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Builds the input schema for the get ability: filter by group XOR by name. + * + * @since 1.1.0 + * + * @param string[] $groups Available settings groups. + * @param string[] $slugs Available exposed setting names. + * @return array The input JSON Schema. + */ + protected static function get_settings_input_schema( array $groups, array $slugs ): array { + return array( + 'type' => 'object', + 'default' => array(), + // Filter by group OR by name, but not both at once. + 'oneOf' => array( + array( + 'title' => esc_html__( 'All settings', 'ai' ), + 'type' => 'object', + 'additionalProperties' => false, + ), + array( + 'title' => esc_html__( 'Filter by group', 'ai' ), + 'type' => 'object', + 'required' => array( 'group' ), + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'enum' => $groups, + 'description' => esc_html__( 'Return only settings that belong to this settings group.', 'ai' ), + ), + ), + 'additionalProperties' => false, + ), + array( + 'title' => esc_html__( 'Filter by name', 'ai' ), + 'type' => 'object', + 'required' => array( 'slugs' ), + 'properties' => array( + 'slugs' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $slugs, + ), + 'description' => esc_html__( 'Return only the settings with these names.', 'ai' ), + ), + ), + 'additionalProperties' => false, + ), + ), + ); + } + + /** + * Returns the settings exposed through the Abilities API. + * + * Reads {@see get_registered_settings()} and keeps only settings flagged with a truthy + * `show_in_abilities` argument. Each entry is keyed by its exposed name and carries the + * underlying option name, the settings group, the registration default, and a JSON Schema + * describing the value. + * + * @since 1.1.0 + * + * @return array}> Settings keyed by exposed name. + */ + protected static function get_exposed_settings(): array { + $settings = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + $show = $args['show_in_abilities'] ?? false; + if ( empty( $show ) ) { + continue; + } + + $option_name = (string) $option_name; + $exposed_name = is_array( $show ) && ! empty( $show['name'] ) ? (string) $show['name'] : $option_name; + + $settings[ $exposed_name ] = array( + 'option' => $option_name, + 'group' => isset( $args['group'] ) ? (string) $args['group'] : '', + 'default' => array_key_exists( 'default', $args ) ? $args['default'] : false, + 'schema' => self::value_schema( $args, $show ), + ); + } + + return $settings; + } + + /** + * Builds the JSON Schema describing a single setting's value. + * + * @since 1.1.0 + * + * @param array $args The setting registration arguments. + * @param bool|array $show The setting's `show_in_abilities` value. + * @return array The value JSON Schema. + */ + protected static function value_schema( array $args, $show ): array { + $schema = array( + 'type' => isset( $args['type'] ) ? (string) $args['type'] : 'string', + ); + if ( ! empty( $args['label'] ) ) { + $schema['title'] = $args['label']; + } + if ( ! empty( $args['description'] ) ) { + $schema['description'] = $args['description']; + } + if ( is_array( $show ) && isset( $show['schema'] ) && is_array( $show['schema'] ) ) { + $schema = array_merge( $schema, $show['schema'] ); + } + + return $schema; + } + + /** + * Casts a stored option value to the type declared in its settings registration. + * + * @since 1.1.0 + * + * @param mixed $value The raw option value. + * @param string $type The registered setting type. + * @return mixed The value cast to the declared type. + */ + protected static function cast_value( $value, string $type ) { + switch ( $type ) { + case 'boolean': + return (bool) $value; + case 'integer': + return (int) $value; + case 'number': + return (float) $value; + case 'array': + case 'object': + return is_array( $value ) ? $value : array(); + default: + return is_scalar( $value ) ? (string) $value : $value; + } + } +} diff --git a/includes/Abilities/Show_In_Abilities.php b/includes/Abilities/Show_In_Abilities.php new file mode 100644 index 000000000..1e5955027 --- /dev/null +++ b/includes/Abilities/Show_In_Abilities.php @@ -0,0 +1,104 @@ + $args The setting registration arguments. + * @param array $defaults The default registration arguments. + * @param string $option_group The settings group. + * @param string $option_name The option name. + * @return array The (possibly amended) registration arguments. + */ + public static function mark_setting( array $args, array $defaults, string $option_group, string $option_name ): array { + $settings = self::settings_map(); + + if ( isset( $settings[ $option_name ] ) && empty( $args['show_in_abilities'] ) ) { + $args['show_in_abilities'] = $settings[ $option_name ]; + } + + return $args; + } + + /** + * Returns the curated core settings to expose, keyed by option name. + * + * The value is whatever `show_in_abilities` should contain: `true`, or an array with + * optional `name` and `schema` keys (mirroring the `show_in_rest` shape). This matches + * the set marked natively by the core `core/settings` implementation. + * + * @since 1.1.0 + * + * @return array> Settings map keyed by option name. + */ + public static function settings_map(): array { + return array( + // General. + 'blogname' => true, + 'blogdescription' => true, + 'siteurl' => true, + 'admin_email' => array( 'schema' => array( 'format' => 'email' ) ), + 'timezone_string' => true, + 'date_format' => true, + 'time_format' => true, + 'start_of_week' => true, + 'WPLANG' => true, + // Writing. + 'use_smilies' => true, + 'default_category' => true, + 'default_post_format' => true, + // Reading. + 'posts_per_page' => true, + 'show_on_front' => true, + 'page_on_front' => true, + 'page_for_posts' => true, + // Discussion. + 'default_ping_status' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + 'default_comment_status' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + ); + } +} diff --git a/includes/Main.php b/includes/Main.php index 23bbf5184..2dda218a5 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -11,6 +11,8 @@ namespace WordPress\AI; +use WordPress\AI\Abilities\Settings\Settings as Settings_Ability; +use WordPress\AI\Abilities\Show_In_Abilities; use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Admin\Activation; use WordPress\AI\Admin\Dashboard\Dashboard_Widgets; @@ -129,6 +131,11 @@ public function initialize_features(): void { // Register our post-related WordPress Abilities. ( new Posts() )->register(); + + // Expose curated core objects to the Abilities API, then register the + // `core/settings` ability (overriding any core-provided copy). + Show_In_Abilities::register(); + Settings_Ability::init(); } catch ( \Throwable $e ) { _doing_it_wrong( __METHOD__, diff --git a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php new file mode 100644 index 000000000..16b8fed54 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php @@ -0,0 +1,254 @@ +ensure_site_category(); + } + + /** + * Tear down test case. + * + * @since 1.1.0 + */ + public function tearDown(): void { + if ( wp_has_ability( 'core/settings' ) ) { + wp_unregister_ability( 'core/settings' ); + } + + remove_filter( 'register_setting_args', array( Show_In_Abilities::class, 'mark_setting' ), 10 ); + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures the `site` ability category exists for the ability to attach to. + * + * @since 1.1.0 + */ + private function ensure_site_category(): void { + if ( wp_has_ability_category( 'site' ) ) { + return; + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + 'site', + array( + 'label' => 'Site', + 'description' => 'Site.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/settings ability inside a faked init action. + * + * @since 1.1.0 + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + Settings::register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as an administrator so the ability's permission check passes. + * + * @since 1.1.0 + */ + private function become_admin(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * The ability is registered in the `site` category and flagged read-only. + * + * @since 1.1.0 + */ + public function test_registers_core_settings_ability(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/settings' ); + + $this->assertNotNull( $ability ); + $this->assertSame( 'core/settings', $ability->get_name() ); + $this->assertSame( 'site', $ability->get_category() ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + } + + /** + * When core already provides core/settings, the plugin's version replaces it. + * + * @since 1.1.0 + */ + public function test_override_replaces_existing_core_settings(): void { + // Simulate a core-provided ability with a different (minimal) shape. + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability( + 'core/settings', + array( + 'label' => 'Core Provided', + 'description' => 'Core provided settings ability.', + 'category' => 'site', + 'execute_callback' => static function (): array { + return array(); + }, + 'permission_callback' => '__return_true', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertSame( 'Core Provided', wp_get_ability( 'core/settings' )->get_label() ); + + $this->register_ability(); + + $ability = wp_get_ability( 'core/settings' ); + $this->assertSame( 'Get Settings', $ability->get_label() ); + // The plugin's shape uses a mutually-exclusive oneOf input schema. + $this->assertArrayHasKey( 'oneOf', $ability->get_input_schema() ); + } + + /** + * The input schema exposes mutually exclusive `group` and `slugs` filters. + * + * @since 1.1.0 + */ + public function test_input_schema_is_one_of_group_or_slugs(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/settings' )->get_input_schema(); + + $this->assertCount( 3, $schema['oneOf'] ); + $this->assertContains( 'reading', $schema['oneOf'][1]['properties']['group']['enum'] ); + $this->assertContains( 'blogname', $schema['oneOf'][2]['properties']['slugs']['items']['enum'] ); + } + + /** + * Without input the ability returns a flat map of correctly typed setting values. + * + * @since 1.1.0 + */ + public function test_returns_flat_typed_values(): void { + $this->become_admin(); + $this->register_ability(); + + update_option( 'blogname', 'My Test Site' ); + update_option( 'posts_per_page', 7 ); + update_option( 'use_smilies', '1' ); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertSame( 'My Test Site', $result['blogname'] ); + $this->assertSame( 7, $result['posts_per_page'] ); + $this->assertTrue( $result['use_smilies'] ); + } + + /** + * The `group` filter narrows the response to a single settings group. + * + * @since 1.1.0 + */ + public function test_filters_by_group(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'group' => 'reading' ) ); + + $this->assertArrayHasKey( 'posts_per_page', $result ); + $this->assertArrayNotHasKey( 'blogname', $result ); + } + + /** + * The `slugs` filter narrows the response to the requested setting names. + * + * @since 1.1.0 + */ + public function test_filters_by_slugs(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'slugs' => array( 'blogname', 'posts_per_page' ) ) ); + + $this->assertSame( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Supplying both `group` and `slugs` violates the `oneOf` and is rejected. + * + * @since 1.1.0 + */ + public function test_rejects_group_and_slugs_together(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( + array( + 'group' => 'reading', + 'slugs' => array( 'blogname' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Users without `manage_options` cannot run the ability. + * + * @since 1.1.0 + */ + public function test_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } +} diff --git a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php new file mode 100644 index 000000000..c08131010 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php @@ -0,0 +1,131 @@ + + */ + private $registered_options = array(); + + /** + * Set up test case. + * + * @since 1.1.0 + */ + public function setUp(): void { + parent::setUp(); + + Show_In_Abilities::register(); + } + + /** + * Tear down test case. + * + * @since 1.1.0 + */ + public function tearDown(): void { + remove_filter( 'register_setting_args', array( Show_In_Abilities::class, 'mark_setting' ), 10 ); + + foreach ( $this->registered_options as $option ) { + unregister_setting( 'group', $option ); + } + $this->registered_options = array(); + + parent::tearDown(); + } + + /** + * Registers a setting and tracks it for cleanup. + * + * @since 1.1.0 + * + * @param string $group The settings group. + * @param string $option The option name. + * @param array $args The registration arguments. + */ + private function register_setting( string $group, string $option, array $args ): void { + $this->registered_options[] = $option; + register_setting( $group, $option, $args ); + } + + /** + * A curated setting is flagged with `show_in_abilities => true`. + * + * @since 1.1.0 + */ + public function test_marks_curated_boolean_setting(): void { + $this->register_setting( 'general', 'blogname', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertTrue( $settings['blogname']['show_in_abilities'] ); + } + + /** + * A curated setting that maps to an array value receives that array verbatim. + * + * @since 1.1.0 + */ + public function test_marks_curated_array_setting(): void { + $this->register_setting( 'discussion', 'default_comment_status', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertSame( + array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + $settings['default_comment_status']['show_in_abilities'] + ); + } + + /** + * A setting that is not in the curated map is left untouched. + * + * @since 1.1.0 + */ + public function test_does_not_mark_uncurated_setting(): void { + $this->register_setting( 'general', 'wpai_not_curated_option', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertTrue( empty( $settings['wpai_not_curated_option']['show_in_abilities'] ) ); + } + + /** + * An explicit `show_in_abilities` value already on the setting is preserved. + * + * @since 1.1.0 + */ + public function test_respects_existing_value(): void { + $this->register_setting( + 'general', + 'blogname', + array( + 'type' => 'string', + 'show_in_abilities' => array( 'name' => 'custom_title' ), + ) + ); + + $settings = get_registered_settings(); + + $this->assertSame( array( 'name' => 'custom_title' ), $settings['blogname']['show_in_abilities'] ); + } +} diff --git a/tests/e2e/specs/abilities/core-settings.spec.js b/tests/e2e/specs/abilities/core-settings.spec.js new file mode 100644 index 000000000..3e70f81ea --- /dev/null +++ b/tests/e2e/specs/abilities/core-settings.spec.js @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Runs the `core/settings` ability through the client-side Abilities API + * (`@wordpress/abilities`), exactly as a consumer would in the browser. + * + * Mirrors the plugin's own import sequence: wait for `@wordpress/core-abilities` + * to be ready, then call `executeAbility` from `@wordpress/abilities`. When the + * client modules are not enqueued (e.g. a WordPress build without the client-side + * Abilities API), returns `{ unavailable: true }` so the test can skip. + * + * @param {import('@playwright/test').Page} page The Playwright page. + * @param {Object} input The ability input. + * @return {Promise} `{ unavailable }`, `{ ok: true, result }`, or `{ ok: false, code }`. + */ +async function runCoreSettings( page, input ) { + return page.evaluate( async ( abilityInput ) => { + let api; + try { + const core = await import( '@wordpress/core-abilities' ); + if ( core && core.ready ) { + await core.ready; + } + api = await import( '@wordpress/abilities' ); + } catch { + api = window.wp && window.wp.abilities; + } + + if ( ! api || typeof api.executeAbility !== 'function' ) { + return { unavailable: true }; + } + + try { + const result = await api.executeAbility( + 'core/settings', + abilityInput + ); + return { ok: true, result }; + } catch ( e ) { + return { ok: false, code: e && e.code ? e.code : null }; + } + }, input ); +} + +const SKIP_REASON = + 'The @wordpress/abilities client is not enqueued in this environment.'; + +test.describe( 'core/settings ability (client-side Abilities API)', () => { + test.beforeEach( async ( { admin } ) => { + // Load wp-admin so the Abilities API client modules and REST nonce are available. + await admin.visitAdminPage( 'index.php' ); + } ); + + test( 'returns a flat, correctly typed map of settings', async ( { + page, + } ) => { + const outcome = await runCoreSettings( page, {} ); + test.skip( outcome.unavailable === true, SKIP_REASON ); + + expect( outcome.ok ).toBe( true ); + // Flat map keyed by setting name (not grouped/nested). + expect( typeof outcome.result.blogname ).toBe( 'string' ); + expect( typeof outcome.result.posts_per_page ).toBe( 'number' ); + expect( typeof outcome.result.use_smilies ).toBe( 'boolean' ); + } ); + + test( 'filters by group', async ( { page } ) => { + const outcome = await runCoreSettings( page, { group: 'reading' } ); + test.skip( outcome.unavailable === true, SKIP_REASON ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result ).toHaveProperty( 'posts_per_page' ); + // Settings from other groups (and schema defaults) must not leak in. + expect( outcome.result ).not.toHaveProperty( 'blogname' ); + expect( outcome.result ).not.toHaveProperty( 'use_smilies' ); + } ); + + test( 'filters by slugs', async ( { page } ) => { + const outcome = await runCoreSettings( page, { + slugs: [ 'blogname', 'posts_per_page' ], + } ); + test.skip( outcome.unavailable === true, SKIP_REASON ); + + expect( outcome.ok ).toBe( true ); + expect( Object.keys( outcome.result ).sort() ).toEqual( [ + 'blogname', + 'posts_per_page', + ] ); + } ); + + test( 'rejects group and slugs together (mutually exclusive)', async ( { + page, + } ) => { + const outcome = await runCoreSettings( page, { + group: 'reading', + slugs: [ 'blogname' ], + } ); + test.skip( outcome.unavailable === true, SKIP_REASON ); + + expect( outcome.ok ).toBe( false ); + expect( outcome.code ).toBe( 'ability_invalid_input' ); + } ); +} ); From a4310138931e8910b803f810988cfbfaea16f735 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:15:27 +0100 Subject: [PATCH 2/9] Run the core/settings client-side e2e from the editor with an experiment enabled The @wordpress/abilities client modules are only added to the editor's import map when an AI experiment is enabled (its script declares them as module_dependencies). Enable Excerpt Generation and drive the test from a new post instead of skipping when the client is unavailable. --- .../e2e/specs/abilities/core-settings.spec.js | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/tests/e2e/specs/abilities/core-settings.spec.js b/tests/e2e/specs/abilities/core-settings.spec.js index 3e70f81ea..69a90d3b5 100644 --- a/tests/e2e/specs/abilities/core-settings.spec.js +++ b/tests/e2e/specs/abilities/core-settings.spec.js @@ -4,37 +4,41 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); /** - * Runs the `core/settings` ability through the client-side Abilities API - * (`@wordpress/abilities`), exactly as a consumer would in the browser. + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +/** + * Runs the `core/settings` ability through the client-side Abilities API, exactly + * as a consumer would in the browser. * - * Mirrors the plugin's own import sequence: wait for `@wordpress/core-abilities` - * to be ready, then call `executeAbility` from `@wordpress/abilities`. When the - * client modules are not enqueued (e.g. a WordPress build without the client-side - * Abilities API), returns `{ unavailable: true }` so the test can skip. + * Mirrors the plugin's own sequence in `src/utils/run-ability.ts`: importing + * `@wordpress/core-abilities` initializes the client store (WordPress core's build + * runs `initialize()` on load and exports the resulting `ready` promise), so we + * await `ready` before calling `executeAbility` from `@wordpress/abilities`. + * + * The client modules are only present in the page's import map once an AI experiment + * is enabled in the block editor (it declares them as `module_dependencies`), which is + * set up in `beforeEach`. * * @param {import('@playwright/test').Page} page The Playwright page. * @param {Object} input The ability input. - * @return {Promise} `{ unavailable }`, `{ ok: true, result }`, or `{ ok: false, code }`. + * @return {Promise} `{ ok: true, result }` or `{ ok: false, code }`. */ async function runCoreSettings( page, input ) { return page.evaluate( async ( abilityInput ) => { - let api; - try { - const core = await import( '@wordpress/core-abilities' ); - if ( core && core.ready ) { - await core.ready; - } - api = await import( '@wordpress/abilities' ); - } catch { - api = window.wp && window.wp.abilities; + const { ready } = await import( '@wordpress/core-abilities' ); + if ( ready ) { + await ready; } - if ( ! api || typeof api.executeAbility !== 'function' ) { - return { unavailable: true }; - } + const { executeAbility } = await import( '@wordpress/abilities' ); try { - const result = await api.executeAbility( + const result = await executeAbility( 'core/settings', abilityInput ); @@ -45,20 +49,25 @@ async function runCoreSettings( page, input ) { }, input ); } -const SKIP_REASON = - 'The @wordpress/abilities client is not enqueued in this environment.'; - test.describe( 'core/settings ability (client-side Abilities API)', () => { - test.beforeEach( async ( { admin } ) => { - // Load wp-admin so the Abilities API client modules and REST nonce are available. - await admin.visitAdminPage( 'index.php' ); + test.beforeEach( async ( { admin, page } ) => { + // Enabling an experiment loads its block-editor script, which declares the + // `@wordpress/abilities` + `@wordpress/core-abilities` modules as dependencies + // and so adds them to the editor's import map. + await enableExperiments( admin, page ); + await enableExperiment( admin, page, 'Excerpt Generation' ); + + // Run from the block editor, where the abilities client modules are available. + await admin.createNewPost( { + postType: 'post', + title: 'core/settings ability test', + } ); } ); test( 'returns a flat, correctly typed map of settings', async ( { page, } ) => { const outcome = await runCoreSettings( page, {} ); - test.skip( outcome.unavailable === true, SKIP_REASON ); expect( outcome.ok ).toBe( true ); // Flat map keyed by setting name (not grouped/nested). @@ -69,11 +78,10 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { test( 'filters by group', async ( { page } ) => { const outcome = await runCoreSettings( page, { group: 'reading' } ); - test.skip( outcome.unavailable === true, SKIP_REASON ); expect( outcome.ok ).toBe( true ); expect( outcome.result ).toHaveProperty( 'posts_per_page' ); - // Settings from other groups (and schema defaults) must not leak in. + // Settings from other groups must not leak in. expect( outcome.result ).not.toHaveProperty( 'blogname' ); expect( outcome.result ).not.toHaveProperty( 'use_smilies' ); } ); @@ -82,7 +90,6 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { const outcome = await runCoreSettings( page, { slugs: [ 'blogname', 'posts_per_page' ], } ); - test.skip( outcome.unavailable === true, SKIP_REASON ); expect( outcome.ok ).toBe( true ); expect( Object.keys( outcome.result ).sort() ).toEqual( [ @@ -98,7 +105,6 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { group: 'reading', slugs: [ 'blogname' ], } ); - test.skip( outcome.unavailable === true, SKIP_REASON ); expect( outcome.ok ).toBe( false ); expect( outcome.code ).toBe( 'ability_invalid_input' ); From 7a2f0a0697b47ea4bc6ee643450bdaedcce7cff0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:30:44 +0100 Subject: [PATCH 3/9] Rename the core/settings 'slugs' input to 'settings' 'settings' reads more naturally than 'slugs' for an ability about settings: executeAbility( 'core/settings', { settings: [ 'blogname' ] } ). --- includes/Abilities/Settings/Settings.php | 26 +++++++++---------- .../Abilities/Settings/SettingsTest.php | 20 +++++++------- .../e2e/specs/abilities/core-settings.spec.js | 8 +++--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php index e90ee932f..028f9f7f0 100644 --- a/includes/Abilities/Settings/Settings.php +++ b/includes/Abilities/Settings/Settings.php @@ -108,10 +108,10 @@ public static function register_get_settings(): void { wp_unregister_ability( 'core/settings' ); } - $settings = self::get_exposed_settings(); - $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); - $slugs = array_keys( $settings ); - $properties = array(); + $settings = self::get_exposed_settings(); + $groups = array_values( array_unique( array_filter( wp_list_pluck( $settings, 'group' ) ) ) ); + $setting_names = array_keys( $settings ); + $properties = array(); foreach ( $settings as $exposed_name => $setting ) { $properties[ $exposed_name ] = $setting['schema']; } @@ -122,7 +122,7 @@ public static function register_get_settings(): void { 'label' => esc_html__( 'Get Settings', 'ai' ), 'description' => esc_html__( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group or by setting name.', 'ai' ), 'category' => self::CATEGORY, - 'input_schema' => self::get_settings_input_schema( $groups, $slugs ), + 'input_schema' => self::get_settings_input_schema( $groups, $setting_names ), 'output_schema' => array( 'type' => 'object', 'description' => esc_html__( 'A map of setting name to its current value.', 'ai' ), @@ -156,14 +156,14 @@ public static function execute_get_settings( $input = array() ): array { $settings = self::get_exposed_settings(); $group = isset( $input['group'] ) ? (string) $input['group'] : ''; - $slugs = isset( $input['slugs'] ) && is_array( $input['slugs'] ) ? $input['slugs'] : array(); + $names = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); $result = array(); foreach ( $settings as $exposed_name => $setting ) { if ( '' !== $group && $setting['group'] !== $group ) { continue; } - if ( ! empty( $slugs ) && ! in_array( $exposed_name, $slugs, true ) ) { + if ( ! empty( $names ) && ! in_array( $exposed_name, $names, true ) ) { continue; } @@ -192,11 +192,11 @@ public static function has_permission(): bool { * * @since 1.1.0 * - * @param string[] $groups Available settings groups. - * @param string[] $slugs Available exposed setting names. + * @param string[] $groups Available settings groups. + * @param string[] $setting_names Available exposed setting names. * @return array The input JSON Schema. */ - protected static function get_settings_input_schema( array $groups, array $slugs ): array { + protected static function get_settings_input_schema( array $groups, array $setting_names ): array { return array( 'type' => 'object', 'default' => array(), @@ -223,13 +223,13 @@ protected static function get_settings_input_schema( array $groups, array $slugs array( 'title' => esc_html__( 'Filter by name', 'ai' ), 'type' => 'object', - 'required' => array( 'slugs' ), + 'required' => array( 'settings' ), 'properties' => array( - 'slugs' => array( + 'settings' => array( 'type' => 'array', 'items' => array( 'type' => 'string', - 'enum' => $slugs, + 'enum' => $setting_names, ), 'description' => esc_html__( 'Return only the settings with these names.', 'ai' ), ), diff --git a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php index 16b8fed54..9adcd144c 100644 --- a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php +++ b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php @@ -153,18 +153,18 @@ public function test_override_replaces_existing_core_settings(): void { } /** - * The input schema exposes mutually exclusive `group` and `slugs` filters. + * The input schema exposes mutually exclusive `group` and `settings` filters. * * @since 1.1.0 */ - public function test_input_schema_is_one_of_group_or_slugs(): void { + public function test_input_schema_is_one_of_group_or_settings(): void { $this->register_ability(); $schema = wp_get_ability( 'core/settings' )->get_input_schema(); $this->assertCount( 3, $schema['oneOf'] ); $this->assertContains( 'reading', $schema['oneOf'][1]['properties']['group']['enum'] ); - $this->assertContains( 'blogname', $schema['oneOf'][2]['properties']['slugs']['items']['enum'] ); + $this->assertContains( 'blogname', $schema['oneOf'][2]['properties']['settings']['items']['enum'] ); } /** @@ -204,32 +204,32 @@ public function test_filters_by_group(): void { } /** - * The `slugs` filter narrows the response to the requested setting names. + * The `settings` filter narrows the response to the requested setting names. * * @since 1.1.0 */ - public function test_filters_by_slugs(): void { + public function test_filters_by_settings(): void { $this->become_admin(); $this->register_ability(); - $result = wp_get_ability( 'core/settings' )->execute( array( 'slugs' => array( 'blogname', 'posts_per_page' ) ) ); + $result = wp_get_ability( 'core/settings' )->execute( array( 'settings' => array( 'blogname', 'posts_per_page' ) ) ); $this->assertSame( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); } /** - * Supplying both `group` and `slugs` violates the `oneOf` and is rejected. + * Supplying both `group` and `settings` violates the `oneOf` and is rejected. * * @since 1.1.0 */ - public function test_rejects_group_and_slugs_together(): void { + public function test_rejects_group_and_settings_together(): void { $this->become_admin(); $this->register_ability(); $result = wp_get_ability( 'core/settings' )->execute( array( - 'group' => 'reading', - 'slugs' => array( 'blogname' ), + 'group' => 'reading', + 'settings' => array( 'blogname' ), ) ); diff --git a/tests/e2e/specs/abilities/core-settings.spec.js b/tests/e2e/specs/abilities/core-settings.spec.js index 69a90d3b5..97a9cde4d 100644 --- a/tests/e2e/specs/abilities/core-settings.spec.js +++ b/tests/e2e/specs/abilities/core-settings.spec.js @@ -86,9 +86,9 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { expect( outcome.result ).not.toHaveProperty( 'use_smilies' ); } ); - test( 'filters by slugs', async ( { page } ) => { + test( 'filters by settings', async ( { page } ) => { const outcome = await runCoreSettings( page, { - slugs: [ 'blogname', 'posts_per_page' ], + settings: [ 'blogname', 'posts_per_page' ], } ); expect( outcome.ok ).toBe( true ); @@ -98,12 +98,12 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { ] ); } ); - test( 'rejects group and slugs together (mutually exclusive)', async ( { + test( 'rejects group and settings together (mutually exclusive)', async ( { page, } ) => { const outcome = await runCoreSettings( page, { group: 'reading', - slugs: [ 'blogname' ], + settings: [ 'blogname' ], } ); expect( outcome.ok ).toBe( false ); From 8970d2d99e3d4534341d976b0e94a7ff15531508 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 15 Jun 2026 20:39:57 +0100 Subject: [PATCH 4/9] Test that core/settings exposes settings registered by other plugins Add an e2e sample plugin that registers a setting with show_in_abilities, map it in .wp-env.test.json, and assert the core/settings ability returns it. --- .wp-env.test.json | 1 + .../e2e-sample-settings.php | 30 +++++++++++++++++++ .../e2e/specs/abilities/core-settings.spec.js | 15 ++++++++++ 3 files changed, 46 insertions(+) create mode 100644 tests/e2e-sample-settings/e2e-sample-settings.php diff --git a/.wp-env.test.json b/.wp-env.test.json index fb5b624ff..1e50bf8d5 100644 --- a/.wp-env.test.json +++ b/.wp-env.test.json @@ -6,6 +6,7 @@ "plugins": [ ".", "./tests/e2e-request-mocking", + "./tests/e2e-sample-settings", "https://downloads.wordpress.org/plugin/ai-provider-for-google.zip", "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], diff --git a/tests/e2e-sample-settings/e2e-sample-settings.php b/tests/e2e-sample-settings/e2e-sample-settings.php new file mode 100644 index 000000000..09eaf3942 --- /dev/null +++ b/tests/e2e-sample-settings/e2e-sample-settings.php @@ -0,0 +1,30 @@ + 'string', + 'label' => 'AI E2E Sample Setting', + 'description' => 'A sample setting exposed to the Abilities API for end-to-end testing.', + 'show_in_abilities' => true, + 'default' => 'sample-default', + ) + ); + } +); diff --git a/tests/e2e/specs/abilities/core-settings.spec.js b/tests/e2e/specs/abilities/core-settings.spec.js index 97a9cde4d..b67c0816c 100644 --- a/tests/e2e/specs/abilities/core-settings.spec.js +++ b/tests/e2e/specs/abilities/core-settings.spec.js @@ -109,4 +109,19 @@ test.describe( 'core/settings ability (client-side Abilities API)', () => { expect( outcome.ok ).toBe( false ); expect( outcome.code ).toBe( 'ability_invalid_input' ); } ); + + test( 'exposes a setting registered by another active plugin', async ( { + page, + } ) => { + // Registered by the `e2e-sample-settings` plugin (mapped in .wp-env.test.json) + // with `show_in_abilities` and a default of `sample-default`. + const outcome = await runCoreSettings( page, { + settings: [ 'ai_e2e_sample_setting' ], + } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result ).toEqual( { + ai_e2e_sample_setting: 'sample-default', + } ); + } ); } ); From ebc3875143f94bffdae2a0c89f62fc4b66a0a344 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 17 Jun 2026 00:18:05 +0100 Subject: [PATCH 5/9] WIP: core/content ability (checkpoint, to be rebased onto develop) --- .mcp.json | 19 + .wp-env.test.json | 1 + includes/Abilities/Content/Content.php | 711 ++++++++++++++++++ includes/Abilities/Show_In_Abilities.php | 70 ++ includes/Main.php | 4 +- .../Abilities/Content/ContentTest.php | 419 +++++++++++ .../Abilities/Show_In_AbilitiesTest.php | 58 ++ .../e2e-sample-content/e2e-sample-content.php | 51 ++ .../e2e/specs/abilities/core-content.spec.js | 126 ++++ 9 files changed, 1458 insertions(+), 1 deletion(-) create mode 100644 .mcp.json create mode 100644 includes/Abilities/Content/Content.php create mode 100644 tests/Integration/Includes/Abilities/Content/ContentTest.php create mode 100644 tests/e2e-sample-content/e2e-sample-content.php create mode 100644 tests/e2e/specs/abilities/core-content.spec.js diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..546442777 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,19 @@ +{ + "mcpServers": { + "xdebug": { + "command": "npx", + "args": ["-y", "xdebug-mcp"], + "env": { "XDEBUG_SOCKET_PATH": "/tmp/xdebug/site-wp-dev-1.sock" } + }, + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--viewport=1440x900", + "--no-usage-statistics", + "--no-performance-crux" + ] + } + } +} diff --git a/.wp-env.test.json b/.wp-env.test.json index 1e50bf8d5..0c547ebb8 100644 --- a/.wp-env.test.json +++ b/.wp-env.test.json @@ -7,6 +7,7 @@ ".", "./tests/e2e-request-mocking", "./tests/e2e-sample-settings", + "./tests/e2e-sample-content", "https://downloads.wordpress.org/plugin/ai-provider-for-google.zip", "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], diff --git a/includes/Abilities/Content/Content.php b/includes/Abilities/Content/Content.php new file mode 100644 index 000000000..371f3b395 --- /dev/null +++ b/includes/Abilities/Content/Content.php @@ -0,0 +1,711 @@ + esc_html__( 'Content', 'ai' ), + 'description' => esc_html__( 'Abilities that retrieve or manage posts and other content.', 'ai' ), + ) + ); + } + + /** + * Registers all content abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since 1.1.0 + */ + public static function register(): void { + self::register_get_content(); + + /* + * A future write-oriented ability can be registered here, reusing the shared + * helpers below (get_exposed_post_types(), format_post(), check_permission()): + * + * self::register_manage_content(); + */ + } + + /** + * Registers the read-only `core/content` ability. + * + * @since 1.1.0 + */ + public static function register_get_content(): void { + // Plugin: unregister any core-provided copy first so the plugin's version wins. + if ( wp_has_ability( 'core/content' ) ) { + wp_unregister_ability( 'core/content' ); + } + + $post_types = array_keys( self::get_exposed_post_types() ); + $statuses = self::get_available_statuses(); + + wp_register_ability( + 'core/content', + array( + 'label' => esc_html__( 'Get Content', 'ai' ), + 'description' => esc_html__( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => self::get_content_input_schema( $post_types, $statuses ), + 'output_schema' => self::get_content_output_schema(), + 'execute_callback' => array( self::class, 'execute_get_content' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + // Opt into REST-level pagination: query mode accepts `page`/`per_page` + // and returns `total`/`total_pages`, which the run controller turns into + // the standard X-WP-Total / X-WP-TotalPages response headers. + 'pagination' => true, + ), + ) + ); + } + + /** + * Permission callback for the `core/content` ability. + * + * Implements defense in depth: this gate decides whether the request may proceed at + * all (coarse, by post type capabilities and requested statuses), while the per-post + * `read_post` meta capability check in {@see self::execute_get_content()} is the + * authoritative, row-level enforcement of author-scoped visibility. + * + * @since 1.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return bool True if the request may proceed, false otherwise. + */ + public static function check_permission( $input = array() ): bool { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + ) { + return current_user_can( 'read' ); + } + + return current_user_can( 'read_post', $post->ID ); + } + + // Query / slug mode requires an exposed post type. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return false; + } + + $post_type_object = $exposed[ $post_type ]; + + if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + return false; + } + + $statuses = self::normalize_statuses( $input ); + + if ( array( 'publish' ) === $statuses ) { + return true; + } + + if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + return true; + } + + if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + foreach ( $statuses as $status ) { + if ( 'private' !== $status && 'publish' !== $status ) { + return false; + } + } + return true; + } + + return false; + } + + /** + * Executes the `core/content` ability. + * + * @since 1.1.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array|\WP_Error A map with a `posts` list, or a WP_Error on failure. + */ + public static function execute_get_content( $input = array() ) { + $input = is_array( $input ) ? $input : array(); + $exposed = self::get_exposed_post_types(); + $fields = self::normalize_fields( $input ); + + // Single-post mode (by ID). + if ( ! empty( $input['id'] ) ) { + $post = get_post( (int) $input['id'] ); + + if ( ! $post + || ! isset( $exposed[ $post->post_type ] ) + || ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) + || ! current_user_can( 'read_post', $post->ID ) + ) { + return self::not_found_error(); + } + + return array( + 'posts' => array( self::format_post( $post, $fields ) ), + 'total' => 1, + 'total_pages' => 1, + ); + } + + // Query / slug mode. + $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { + return self::not_found_error(); + } + + $per_page = self::normalize_per_page( $input ); + $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => self::normalize_statuses( $input ), + 'posts_per_page' => $per_page, + 'paged' => $page, + 'ignore_sticky_posts' => true, + ); + + if ( ! empty( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( (string) $input['slug'] ); + } + + if ( ! empty( $input['author'] ) ) { + $query_args['author'] = (int) $input['author']; + } + + if ( isset( $input['parent'] ) ) { + $query_args['post_parent'] = (int) $input['parent']; + } + + $query = new WP_Query( $query_args ); + + $posts = array(); + foreach ( $query->posts as $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + $posts[] = self::format_post( $post, $fields ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + } + + /** + * Normalizes the requested per-page value to the supported bounds. + * + * @since 1.1.0 + * + * @param array $input The ability input. + * @return int The clamped per-page value. + */ + protected static function normalize_per_page( array $input ): int { + $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + + return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); + } + + /** + * Returns the post types exposed through the Abilities API, keyed by name. + * + * @since 1.1.0 + * + * @return array Exposed post type objects keyed by name. + */ + protected static function get_exposed_post_types(): array { + $exposed = array(); + + foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) { + if ( empty( $post_type_object->show_in_abilities ) ) { + continue; + } + $exposed[ $post_type_object->name ] = $post_type_object; + } + + return $exposed; + } + + /** + * Returns the post statuses that may be requested through the ability. + * + * @since 1.1.0 + * + * @return string[] List of public, non-internal post status slugs. + */ + protected static function get_available_statuses(): array { + return array_values( get_post_stati( array( 'internal' => false ) ) ); + } + + /** + * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. + * + * @since 1.1.0 + * + * @param array $input The ability input. + * @return string[] Normalized list of post status slugs. + */ + protected static function normalize_statuses( array $input ): array { + $statuses = $input['status'] ?? array( 'publish' ); + if ( ! is_array( $statuses ) || array() === $statuses ) { + return array( 'publish' ); + } + + return array_map( 'sanitize_key', $statuses ); + } + + /** + * Normalizes the requested fields to the supported set, defaulting to all fields. + * + * @since 1.1.0 + * + * @param array $input The ability input. + * @return string[] List of requested field names. + */ + protected static function normalize_fields( array $input ): array { + if ( empty( $input['fields'] ) || ! is_array( $input['fields'] ) ) { + return self::$fields; + } + + $fields = array_intersect( self::$fields, array_map( 'strval', $input['fields'] ) ); + + return array() === $fields ? self::$fields : array_values( $fields ); + } + + /** + * Builds the input schema for the `core/content` ability. + * + * @since 1.1.0 + * + * @param string[] $post_types Exposed post type names. + * @param string[] $statuses Requestable post status slugs. + * @return array The input JSON Schema. + */ + protected static function get_content_input_schema( array $post_types, array $statuses ): array { + return array( + 'type' => 'object', + 'default' => array(), + // `post_type` is required unless a single post is requested by `id`. + 'anyOf' => array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'enum' => $post_types, + 'description' => esc_html__( 'Post type to retrieve. Required unless `id` is provided.', 'ai' ), + ), + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => esc_html__( 'Retrieve a single post by ID. When provided, `post_type` is optional.', 'ai' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => esc_html__( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.', 'ai' ), + ), + 'status' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'default' => array( 'publish' ), + 'items' => array( + 'type' => 'string', + 'enum' => $statuses, + ), + 'description' => esc_html__( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.', 'ai' ), + ), + 'author' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => esc_html__( 'Filter by author user ID.', 'ai' ), + ), + 'parent' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => esc_html__( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.', 'ai' ), + ), + 'fields' => array( + 'type' => 'array', + 'uniqueItems' => true, + 'items' => array( + 'type' => 'string', + 'enum' => self::$fields, + ), + 'description' => esc_html__( 'Limit each returned post to these fields. If omitted, all supported fields are returned.', 'ai' ), + ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'default' => 1, + 'description' => esc_html__( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.', 'ai' ), + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_PER_PAGE, + 'default' => self::DEFAULT_PER_PAGE, + 'description' => esc_html__( 'Maximum number of posts to return per page in query mode.', 'ai' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Builds the output schema for the `core/content` ability. + * + * @since 1.1.0 + * + * @return array The output JSON Schema. + */ + protected static function get_content_output_schema(): array { + $post_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The post ID.', 'ai' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => esc_html__( 'The post type.', 'ai' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => esc_html__( 'The post status.', 'ai' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => esc_html__( 'The publication date, in ISO 8601 format (GMT).', 'ai' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => esc_html__( 'The last modified date, in ISO 8601 format (GMT).', 'ai' ), + ), + 'slug' => array( + 'type' => 'string', + 'description' => esc_html__( 'The post slug.', 'ai' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => esc_html__( 'The permalink URL.', 'ai' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => esc_html__( 'The post title. Present when the post type supports titles.', 'ai' ), + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => esc_html__( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.', 'ai' ), + ), + 'raw_content' => array( + 'type' => 'string', + 'description' => esc_html__( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.', 'ai' ), + ), + 'author' => array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The author user ID.', 'ai' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => esc_html__( 'The author display name.', 'ai' ), + ), + ), + 'description' => esc_html__( 'The post author. Present when the post type supports authors.', 'ai' ), + ), + 'parent' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The parent post ID. Present for hierarchical post types.', 'ai' ), + ), + ), + ); + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => esc_html__( 'The posts matching the request. A single-element list when requested by ID.', 'ai' ), + 'items' => $post_schema, + ), + 'total' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.', 'ai' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.', 'ai' ), + ), + ), + ); + } + + /** + * Formats a post into the ability output shape. + * + * @since 1.1.0 + * + * @param \WP_Post $post The post object. + * @param string[] $fields The requested field names. + * @return array The formatted post data. + */ + protected static function format_post( WP_Post $post, array $fields ): array { + $type = $post->post_type; + $wants = static function ( string $field ) use ( $fields ): bool { + return in_array( $field, $fields, true ); + }; + $protected = post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ); + + $data = array(); + + if ( $wants( 'id' ) ) { + $data['id'] = (int) $post->ID; + } + if ( $wants( 'type' ) ) { + $data['type'] = $type; + } + if ( $wants( 'status' ) ) { + $data['status'] = $post->post_status; + } + if ( $wants( 'date' ) ) { + $data['date'] = self::format_gmt_date( $post, 'date' ); + } + if ( $wants( 'modified' ) ) { + $data['modified'] = self::format_gmt_date( $post, 'modified' ); + } + if ( $wants( 'slug' ) ) { + $data['slug'] = $post->post_name; + } + if ( $wants( 'link' ) ) { + $data['link'] = (string) get_permalink( $post ); + } + + if ( $wants( 'title' ) && post_type_supports( $type, 'title' ) ) { + $data['title'] = self::get_title( $post ); + } + + if ( $wants( 'excerpt' ) && post_type_supports( $type, 'excerpt' ) ) { + $data['excerpt'] = $protected ? '' : (string) get_the_excerpt( $post ); + } + + if ( $wants( 'raw_content' ) && post_type_supports( $type, 'editor' ) ) { + $data['raw_content'] = $protected ? '' : (string) $post->post_content; + } + + if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) { + $author = get_userdata( (int) $post->post_author ); + $data['author'] = array( + 'id' => (int) $post->post_author, + 'display_name' => $author ? $author->display_name : '', + ); + } + + if ( $wants( 'parent' ) && is_post_type_hierarchical( $type ) ) { + $data['parent'] = (int) $post->post_parent; + } + + return $data; + } + + /** + * Returns the post title with the protected/private prefixes stripped. + * + * @since 1.1.0 + * + * @param \WP_Post $post The post object. + * @return string The post title. + */ + protected static function get_title( WP_Post $post ): string { + $strip = array( self::class, 'return_raw_title_format' ); + add_filter( 'protected_title_format', $strip ); + add_filter( 'private_title_format', $strip ); + $title = get_the_title( $post ); + remove_filter( 'protected_title_format', $strip ); + remove_filter( 'private_title_format', $strip ); + + return $title; + } + + /** + * Returns the raw title format, used to strip protected/private title prefixes. + * + * @since 1.1.0 + * + * @return string The unprefixed title format. + */ + public static function return_raw_title_format(): string { + return '%s'; + } + + /** + * Formats a post date field as an ISO 8601 string in GMT. + * + * @since 1.1.0 + * + * @param \WP_Post $post The post object. + * @param string $field Either 'date' or 'modified'. + * @return string The ISO 8601 date, or an empty string if unavailable. + */ + protected static function format_gmt_date( WP_Post $post, string $field ): string { + $datetime = get_post_datetime( $post, $field, 'gmt' ); + if ( $datetime ) { + return $datetime->format( 'c' ); + } + + $local = 'modified' === $field ? $post->post_modified : $post->post_date; + $timestamp = mysql2date( 'U', $local, false ); + + return $timestamp ? gmdate( 'c', (int) $timestamp ) : ''; + } + + /** + * Builds the uniform not-found error. + * + * @since 1.1.0 + * + * @return \WP_Error The not-found error. + */ + protected static function not_found_error(): WP_Error { + return new WP_Error( + 'content_not_found', + esc_html__( 'The requested content was not found.', 'ai' ), + array( 'status' => 404 ) + ); + } +} diff --git a/includes/Abilities/Show_In_Abilities.php b/includes/Abilities/Show_In_Abilities.php index 1e5955027..9d66eeb9d 100644 --- a/includes/Abilities/Show_In_Abilities.php +++ b/includes/Abilities/Show_In_Abilities.php @@ -38,6 +38,14 @@ class Show_In_Abilities { */ public static function register(): void { add_filter( 'register_setting_args', array( self::class, 'mark_setting' ), 10, 4 ); + add_filter( 'register_post_type_args', array( self::class, 'mark_post_type' ), 10, 2 ); + + /* + * Core post types (post, page) are registered very early — during bootstrap and on + * `init` priority 0 — which is typically before this component runs, so the filter + * above would miss them. Mark any already-registered curated post types directly. + */ + self::mark_registered_post_types(); } /** @@ -64,6 +72,68 @@ public static function mark_setting( array $args, array $defaults, string $optio return $args; } + /** + * Adds the `show_in_abilities` flag to curated core post types as they are registered. + * + * Mirrors {@see self::mark_setting()}: respects an explicit `show_in_abilities` value + * already present on the post type (for example once core ships it natively), only + * filling it in when absent. + * + * @since 1.1.0 + * + * @param array $args The post type registration arguments. + * @param string $post_type The post type key. + * @return array The (possibly amended) registration arguments. + */ + public static function mark_post_type( array $args, string $post_type ): array { + $post_types = self::post_types_map(); + + if ( isset( $post_types[ $post_type ] ) && empty( $args['show_in_abilities'] ) ) { + $args['show_in_abilities'] = $post_types[ $post_type ]; + } + + return $args; + } + + /** + * Marks already-registered curated post types as exposed to abilities. + * + * The `register_post_type_args` filter only affects post types registered after it is + * added, but core post types are registered during bootstrap. This patches the existing + * post type objects directly so the polyfill works regardless of when it runs. + * {@see WP_Post_Type} allows dynamic properties, so this is safe on stock WordPress. + * + * @since 1.1.0 + */ + public static function mark_registered_post_types(): void { + foreach ( self::post_types_map() as $post_type => $show ) { + $object = get_post_type_object( $post_type ); + if ( ! ( $object instanceof \WP_Post_Type ) || ! empty( $object->show_in_abilities ) ) { + continue; + } + + $object->show_in_abilities = $show; + } + } + + /** + * Returns the curated core post types to expose, keyed by post type key. + * + * The value is whatever `show_in_abilities` should contain: `true`, or an array + * reserved for enabling specific operations in the future. This matches the set + * marked natively by the core `core/content` implementation (`post` and `page`). + * + * @since 1.1.0 + * + * @return array> Post types map keyed by post type key. + */ + public static function post_types_map(): array { + return array( + 'post' => true, + 'page' => true, + ); + } + /** * Returns the curated core settings to expose, keyed by option name. * diff --git a/includes/Main.php b/includes/Main.php index 2dda218a5..d55aab29d 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -11,6 +11,7 @@ namespace WordPress\AI; +use WordPress\AI\Abilities\Content\Content as Content_Ability; use WordPress\AI\Abilities\Settings\Settings as Settings_Ability; use WordPress\AI\Abilities\Show_In_Abilities; use WordPress\AI\Abilities\Utilities\Posts; @@ -133,9 +134,10 @@ public function initialize_features(): void { ( new Posts() )->register(); // Expose curated core objects to the Abilities API, then register the - // `core/settings` ability (overriding any core-provided copy). + // `core/settings` and `core/content` abilities (overriding any core-provided copies). Show_In_Abilities::register(); Settings_Ability::init(); + Content_Ability::init(); } catch ( \Throwable $e ) { _doing_it_wrong( __METHOD__, diff --git a/tests/Integration/Includes/Abilities/Content/ContentTest.php b/tests/Integration/Includes/Abilities/Content/ContentTest.php new file mode 100644 index 000000000..145083d92 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Content/ContentTest.php @@ -0,0 +1,419 @@ +ensure_content_category(); + } + + /** + * Tear down test case. + * + * @since 1.1.0 + */ + public function tearDown(): void { + if ( wp_has_ability( 'core/content' ) ) { + wp_unregister_ability( 'core/content' ); + } + + remove_filter( 'register_setting_args', array( Show_In_Abilities::class, 'mark_setting' ), 10 ); + remove_filter( 'register_post_type_args', array( Show_In_Abilities::class, 'mark_post_type' ), 10 ); + + // Restore the curated post types to their unmarked state to avoid leaking into other tests. + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( ! $object ) { + continue; + } + + unset( $object->show_in_abilities ); + } + + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures the `content` ability category exists for the ability to attach to. + * + * @since 1.1.0 + */ + private function ensure_content_category(): void { + if ( wp_has_ability_category( 'content' ) ) { + return; + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + 'content', + array( + 'label' => 'Content', + 'description' => 'Content.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/content ability inside a faked init action. + * + * @since 1.1.0 + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + Content::register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as a user with the given role and returns the user ID. + * + * @param string $role The role to create the user with. + * @return int The new user ID. + */ + private function login_as( string $role ): int { + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + wp_set_current_user( $user_id ); + return $user_id; + } + + /** + * The ability is registered in the `content` category and flagged read-only. + * + * @since 1.1.0 + */ + public function test_registers_core_content_ability(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/content' ); + + $this->assertNotNull( $ability ); + $this->assertSame( 'core/content', $ability->get_name() ); + $this->assertSame( 'content', $ability->get_category() ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + } + + /** + * When core already provides core/content, the plugin's version replaces it. + * + * @since 1.1.0 + */ + public function test_override_replaces_existing_core_content(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability( + 'core/content', + array( + 'label' => 'Core Provided', + 'description' => 'Core provided content ability.', + 'category' => 'content', + 'execute_callback' => static function (): array { + return array( 'posts' => array() ); + }, + 'permission_callback' => '__return_true', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertSame( 'Core Provided', wp_get_ability( 'core/content' )->get_label() ); + + $this->register_ability(); + + $this->assertSame( 'Get Content', wp_get_ability( 'core/content' )->get_label() ); + } + + /** + * The input schema requires either `id` or `post_type` and exposes only marked types. + * + * @since 1.1.0 + */ + public function test_input_schema_requires_id_or_post_type(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/content' )->get_input_schema(); + + $this->assertSame( + array( + array( 'required' => array( 'id' ) ), + array( 'required' => array( 'post_type' ) ), + ), + $schema['anyOf'] + ); + + $enum = $schema['properties']['post_type']['enum']; + $this->assertContains( 'post', $enum ); + $this->assertContains( 'page', $enum ); + } + + /** + * A published post can be fetched by ID. + * + * @since 1.1.0 + */ + public function test_get_single_published_post_by_id(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Hello Content', + 'post_content' => 'Body here.', + 'post_status' => 'publish', + ) + ); + + $result = wp_get_ability( 'core/content' )->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertCount( 1, $result['posts'] ); + $this->assertSame( $post_id, $result['posts'][0]['id'] ); + $this->assertSame( 'Hello Content', $result['posts'][0]['title'] ); + $this->assertSame( 'Body here.', $result['posts'][0]['raw_content'] ); + } + + /** + * Query mode returns only published posts by default. + * + * @since 1.1.0 + */ + public function test_query_returns_only_published_by_default(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $published = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $draft = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + $result = wp_get_ability( 'core/content' )->execute( array( 'post_type' => 'post' ) ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $published, $ids ); + $this->assertNotContains( $draft, $ids ); + } + + /** + * Querying by slug without a post type is rejected by the input schema. + * + * @since 1.1.0 + */ + public function test_query_by_slug_requires_post_type(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content' )->execute( array( 'slug' => 'whatever' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * The `fields` filter limits the returned keys. + * + * @since 1.1.0 + */ + public function test_fields_filter_limits_returned_keys(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'title' ), + ) + ); + + $this->assertSame( array( 'id', 'title' ), array_keys( $result['posts'][0] ) ); + } + + /** + * Logged-out users cannot run the ability. + * + * @since 1.1.0 + */ + public function test_logged_out_user_is_denied(): void { + wp_set_current_user( 0 ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content' )->execute( array( 'post_type' => 'post' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * Subscribers cannot request draft posts. + * + * @since 1.1.0 + */ + public function test_subscriber_cannot_request_draft_status(): void { + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * An author can pass the draft gate but only sees their own drafts. + * + * @since 1.1.0 + */ + public function test_author_cannot_see_other_authors_drafts(): void { + $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); + $author_b = self::factory()->user->create( array( 'role' => 'author' ) ); + + $draft_a = self::factory()->post->create( + array( + 'post_author' => $author_a, + 'post_status' => 'draft', + ) + ); + $draft_b = self::factory()->post->create( + array( + 'post_author' => $author_b, + 'post_status' => 'draft', + ) + ); + + wp_set_current_user( $author_b ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content' )->execute( + array( + 'post_type' => 'post', + 'status' => array( 'draft' ), + ) + ); + $ids = wp_list_pluck( $result['posts'], 'id' ); + + $this->assertContains( $draft_b, $ids ); + $this->assertNotContains( $draft_a, $ids ); + } + + /** + * Password-protected content is withheld from users who cannot edit the post. + * + * @since 1.1.0 + */ + public function test_password_protected_content_withheld_from_non_editor(): void { + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_password' => 'secret', + 'post_content' => 'Top secret body.', + ) + ); + + $this->login_as( 'subscriber' ); + $this->register_ability(); + + $result = wp_get_ability( 'core/content' )->execute( + array( + 'id' => $post_id, + 'fields' => array( 'id', 'raw_content' ), + ) + ); + + $this->assertSame( '', $result['posts'][0]['raw_content'] ); + } + + /** + * Query mode paginates with `page`/`per_page` and reports totals. + * + * @since 1.1.0 + */ + public function test_query_paginates_and_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + self::factory()->post->create_many( 3, array( 'post_status' => 'publish' ) ); + + $page1 = wp_get_ability( 'core/content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 1, + ) + ); + + $this->assertCount( 2, $page1['posts'] ); + $this->assertGreaterThanOrEqual( 3, $page1['total'] ); + $this->assertSame( (int) ceil( $page1['total'] / 2 ), $page1['total_pages'] ); + + $page2 = wp_get_ability( 'core/content' )->execute( + array( + 'post_type' => 'post', + 'per_page' => 2, + 'page' => 2, + ) + ); + + $this->assertNotEmpty( $page2['posts'] ); + $this->assertSame( $page1['total'], $page2['total'] ); + } + + /** + * A single post fetched by ID still reports pagination totals of one. + * + * @since 1.1.0 + */ + public function test_single_post_reports_totals(): void { + $this->login_as( 'administrator' ); + $this->register_ability(); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $result = wp_get_ability( 'core/content' )->execute( array( 'id' => $post_id ) ); + + $this->assertSame( 1, $result['total'] ); + $this->assertSame( 1, $result['total_pages'] ); + } +} diff --git a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php index c08131010..392330f5a 100644 --- a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php +++ b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php @@ -44,12 +44,23 @@ public function setUp(): void { */ public function tearDown(): void { remove_filter( 'register_setting_args', array( Show_In_Abilities::class, 'mark_setting' ), 10 ); + remove_filter( 'register_post_type_args', array( Show_In_Abilities::class, 'mark_post_type' ), 10 ); foreach ( $this->registered_options as $option ) { unregister_setting( 'group', $option ); } $this->registered_options = array(); + // Restore the curated post types to their unmarked state. + foreach ( array( 'post', 'page' ) as $post_type ) { + $object = get_post_type_object( $post_type ); + if ( ! $object ) { + continue; + } + + unset( $object->show_in_abilities ); + } + parent::tearDown(); } @@ -128,4 +139,51 @@ public function test_respects_existing_value(): void { $this->assertSame( array( 'name' => 'custom_title' ), $settings['blogname']['show_in_abilities'] ); } + + /** + * Curated core post types are marked directly, since they register before the filter. + * + * @since 1.1.0 + */ + public function test_marks_curated_registered_post_types(): void { + // Show_In_Abilities::register() ran in setUp and patches existing post types. + $this->assertNotEmpty( get_post_type_object( 'post' )->show_in_abilities ); + $this->assertNotEmpty( get_post_type_object( 'page' )->show_in_abilities ); + } + + /** + * The post type args filter marks a curated post type when it is registered. + * + * @since 1.1.0 + */ + public function test_filter_marks_curated_post_type(): void { + $args = Show_In_Abilities::mark_post_type( array(), 'page' ); + + $this->assertTrue( $args['show_in_abilities'] ); + } + + /** + * The post type args filter leaves uncurated post types untouched. + * + * @since 1.1.0 + */ + public function test_filter_skips_uncurated_post_type(): void { + $args = Show_In_Abilities::mark_post_type( array(), 'wpai_not_curated_cpt' ); + + $this->assertTrue( empty( $args['show_in_abilities'] ) ); + } + + /** + * An explicit `show_in_abilities` value already on the post type is preserved. + * + * @since 1.1.0 + */ + public function test_filter_respects_existing_post_type_value(): void { + $args = Show_In_Abilities::mark_post_type( + array( 'show_in_abilities' => array( 'custom' => true ) ), + 'post' + ); + + $this->assertSame( array( 'custom' => true ), $args['show_in_abilities'] ); + } } diff --git a/tests/e2e-sample-content/e2e-sample-content.php b/tests/e2e-sample-content/e2e-sample-content.php new file mode 100644 index 000000000..226506ba9 --- /dev/null +++ b/tests/e2e-sample-content/e2e-sample-content.php @@ -0,0 +1,51 @@ + 'AI E2E Sample', + 'public' => true, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'supports' => array( 'title', 'editor', 'excerpt', 'author' ), + ) + ); + }, + 5 +); + +// Seed a published sample post once, after the post type is registered. +add_action( + 'init', + static function (): void { + if ( get_page_by_path( 'ai-e2e-sample-content', OBJECT, 'ai_e2e_sample' ) ) { + return; + } + + wp_insert_post( + array( + 'post_type' => 'ai_e2e_sample', + 'post_name' => 'ai-e2e-sample-content', + 'post_title' => 'AI E2E Sample Content', + 'post_content' => 'Sample content body for end-to-end testing.', + 'post_status' => 'publish', + ) + ); + }, + 20 +); diff --git a/tests/e2e/specs/abilities/core-content.spec.js b/tests/e2e/specs/abilities/core-content.spec.js new file mode 100644 index 000000000..f7914a011 --- /dev/null +++ b/tests/e2e/specs/abilities/core-content.spec.js @@ -0,0 +1,126 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +/** + * Runs the `core/content` ability through the client-side Abilities API, exactly + * as a consumer would in the browser. + * + * Mirrors the plugin's own sequence in `src/utils/run-ability.ts`: importing + * `@wordpress/core-abilities` initializes the client store (WordPress core's build + * runs `initialize()` on load and exports the resulting `ready` promise), so we + * await `ready` before calling `executeAbility` from `@wordpress/abilities`. + * + * The client modules are only present in the page's import map once an AI experiment + * is enabled in the block editor (it declares them as `module_dependencies`), which is + * set up in `beforeEach`. + * + * @param {import('@playwright/test').Page} page The Playwright page. + * @param {Object} input The ability input. + * @return {Promise} `{ ok: true, result }` or `{ ok: false, code }`. + */ +async function runCoreContent( page, input ) { + return page.evaluate( async ( abilityInput ) => { + const { ready } = await import( '@wordpress/core-abilities' ); + if ( ready ) { + await ready; + } + + const { executeAbility } = await import( '@wordpress/abilities' ); + + try { + const result = await executeAbility( 'core/content', abilityInput ); + return { ok: true, result }; + } catch ( e ) { + return { ok: false, code: e && e.code ? e.code : null }; + } + }, input ); +} + +test.describe( 'core/content ability (client-side Abilities API)', () => { + test.beforeEach( async ( { admin, page } ) => { + // Enabling an experiment loads its block-editor script, which declares the + // `@wordpress/abilities` + `@wordpress/core-abilities` modules as dependencies + // and so adds them to the editor's import map. + await enableExperiments( admin, page ); + await enableExperiment( admin, page, 'Excerpt Generation' ); + + // Run from the block editor, where the abilities client modules are available. + await admin.createNewPost( { + postType: 'post', + title: 'core/content ability test', + } ); + } ); + + test( 'returns a posts list of the requested post type', async ( { + page, + } ) => { + const outcome = await runCoreContent( page, { post_type: 'post' } ); + + expect( outcome.ok ).toBe( true ); + expect( Array.isArray( outcome.result.posts ) ).toBe( true ); + // Pagination totals travel in the body (and as X-WP-Total headers when core supports it). + expect( typeof outcome.result.total ).toBe( 'number' ); + expect( typeof outcome.result.total_pages ).toBe( 'number' ); + for ( const post of outcome.result.posts ) { + expect( post.type ).toBe( 'post' ); + expect( post.status ).toBe( 'publish' ); + } + } ); + + test( 'paginates with page and per_page', async ( { page } ) => { + const outcome = await runCoreContent( page, { + post_type: 'post', + per_page: 1, + page: 1, + } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result.posts.length ).toBeLessThanOrEqual( 1 ); + } ); + + test( 'limits each post to the requested fields', async ( { page } ) => { + const outcome = await runCoreContent( page, { + post_type: 'post', + fields: [ 'id', 'title' ], + } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result.posts.length ).toBeGreaterThan( 0 ); + for ( const post of outcome.result.posts ) { + expect( Object.keys( post ).sort() ).toEqual( [ 'id', 'title' ] ); + } + } ); + + test( 'rejects a slug query without a post type', async ( { page } ) => { + const outcome = await runCoreContent( page, { slug: 'whatever' } ); + + expect( outcome.ok ).toBe( false ); + expect( outcome.code ).toBe( 'ability_invalid_input' ); + } ); + + test( 'exposes a post type registered by another active plugin', async ( { + page, + } ) => { + // The `e2e-sample-content` plugin (mapped in .wp-env.test.json) registers the + // `ai_e2e_sample` post type with `show_in_abilities` and seeds a published post. + const outcome = await runCoreContent( page, { + post_type: 'ai_e2e_sample', + slug: 'ai-e2e-sample-content', + } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result.posts ).toHaveLength( 1 ); + expect( outcome.result.posts[ 0 ].title ).toBe( 'AI E2E Sample Content' ); + expect( outcome.result.posts[ 0 ].slug ).toBe( 'ai-e2e-sample-content' ); + } ); +} ); From 30661d4688d75e1959e7ce160efb24ed10fc416a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 18 Jun 2026 10:34:07 +0100 Subject: [PATCH 6/9] Stop tracking .mcp.json (local-only MCP dev config) The .mcp.json file holds developer-local MCP server config (xdebug, chrome-devtools) and should not be in version control. Remove it from the repo and add it to .gitignore; the file is kept locally. --- .gitignore | 1 + .mcp.json | 19 ------------------- 2 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 .mcp.json diff --git a/.gitignore b/.gitignore index 7f5b698e1..bd6400f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ build-types/ # AI files .gemini/ .claude/ +.mcp.json AGENTS.md GEMINI.md CLAUDE.md diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 546442777..000000000 --- a/.mcp.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "mcpServers": { - "xdebug": { - "command": "npx", - "args": ["-y", "xdebug-mcp"], - "env": { "XDEBUG_SOCKET_PATH": "/tmp/xdebug/site-wp-dev-1.sock" } - }, - "chrome-devtools": { - "command": "npx", - "args": [ - "-y", - "chrome-devtools-mcp@latest", - "--viewport=1440x900", - "--no-usage-statistics", - "--no-performance-crux" - ] - } - } -} From 4c253a2654828a36a93d128c85e18c7c87cc6e87 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 18 Jun 2026 10:52:41 +0100 Subject: [PATCH 7/9] Ignore local CLAUDE.local.md notes file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bd6400f8c..0b78f30c8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ CLAUDE.md # Build ai.zip + +# Claude local notes (untracked) +/CLAUDE.local.md From d8271186e2d8fef9bbd68799d5655914da87adc2 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 18 Jun 2026 10:52:41 +0100 Subject: [PATCH 8/9] Fix core/content field-limiting e2e by seeding published posts The e2e global setup deletes all `post` entries, so querying `post_type: 'post'` for the field-limiting test returned zero posts in clean CI and failed the `posts.length > 0` assertion. Seed published posts via `requestUtils.createPost` in a `beforeAll` (tracking their IDs) and remove only those in `afterAll`, then query `post` as before. --- .../e2e/specs/abilities/core-content.spec.js | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/e2e/specs/abilities/core-content.spec.js b/tests/e2e/specs/abilities/core-content.spec.js index f7914a011..cd2ee3cd6 100644 --- a/tests/e2e/specs/abilities/core-content.spec.js +++ b/tests/e2e/specs/abilities/core-content.spec.js @@ -47,6 +47,36 @@ async function runCoreContent( page, input ) { } test.describe( 'core/content ability (client-side Abilities API)', () => { + const seededPostIds = []; + + test.beforeAll( async ( { requestUtils } ) => { + // The global setup deletes all `post` entries, so seed a few published + // posts for the query-mode tests to retrieve. + const posts = await Promise.all( + [ 'one', 'two', 'three' ].map( ( suffix ) => + requestUtils.createPost( { + title: `core/content seeded post ${ suffix }`, + status: 'publish', + } ) + ) + ); + seededPostIds.push( ...posts.map( ( post ) => post.id ) ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + // Remove only the posts seeded here, leaving any other specs' content alone. + await Promise.all( + seededPostIds.map( ( id ) => + requestUtils.rest( { + method: 'DELETE', + path: `/wp/v2/posts/${ id }`, + params: { force: true }, + } ) + ) + ); + seededPostIds.length = 0; + } ); + test.beforeEach( async ( { admin, page } ) => { // Enabling an experiment loads its block-editor script, which declares the // `@wordpress/abilities` + `@wordpress/core-abilities` modules as dependencies @@ -120,7 +150,11 @@ test.describe( 'core/content ability (client-side Abilities API)', () => { expect( outcome.ok ).toBe( true ); expect( outcome.result.posts ).toHaveLength( 1 ); - expect( outcome.result.posts[ 0 ].title ).toBe( 'AI E2E Sample Content' ); - expect( outcome.result.posts[ 0 ].slug ).toBe( 'ai-e2e-sample-content' ); + expect( outcome.result.posts[ 0 ].title ).toBe( + 'AI E2E Sample Content' + ); + expect( outcome.result.posts[ 0 ].slug ).toBe( + 'ai-e2e-sample-content' + ); } ); } ); From 88df83c16cf86db16277f5fd9495d6240c005678 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 18 Jun 2026 11:01:10 +0100 Subject: [PATCH 9/9] Apply core/settings review feedback to the core/content ability Re-applied on top of the current branch tip (a prior push of these changes was overwritten by a force-push). Mirrors the core/settings review fixes that apply here: - Memoize the exposed post types so the input schema and the permission/execute callbacks derive from a single walk of the registered post types. - Default the input schema to an empty object so the type:object default serializes as {}. - Use __() instead of esc_html__(), and @since x.x.x per CONTRIBUTING.md. - Harden input/value handling (type guards, capability resolver, non-negative int helper, dynamic-property annotation) so the new code passes PHPStan at level max. The `content` ability-category fallback is kept on purpose: unlike `site`, `content` is a new category not present on the plugin's minimum WordPress (7.0). --- includes/Abilities/Content/Content.php | 209 +++++++++++------- includes/Abilities/Show_In_Abilities.php | 18 +- .../Abilities/Content/ContentTest.php | 36 +-- .../Abilities/Show_In_AbilitiesTest.php | 26 +-- 4 files changed, 169 insertions(+), 120 deletions(-) diff --git a/includes/Abilities/Content/Content.php b/includes/Abilities/Content/Content.php index 371f3b395..a090cd330 100644 --- a/includes/Abilities/Content/Content.php +++ b/includes/Abilities/Content/Content.php @@ -4,7 +4,7 @@ * * @package WordPress\AI * - * @since 1.1.0 + * @since x.x.x */ declare( strict_types=1 ); @@ -28,19 +28,18 @@ * * This class is kept almost identical to the WordPress core class `WP_Content_Abilities` * so the two implementations stay in sync. Differences from the core class are marked with - * `// Plugin:` comments. Additionally, all user-facing strings use esc_html__() with the - * 'ai' text domain rather than core's __(). + * `// Plugin:` comments. Additionally, all user-facing strings use the 'ai' text domain. * * @internal This class should not be used outside the plugin and there is no guarantee of backwards compatibility. * - * @since 1.1.0 + * @since x.x.x */ class Content { /** * The ability category used for content abilities. * - * @since 1.1.0 + * @since x.x.x * @var string */ public const CATEGORY = 'content'; @@ -48,7 +47,7 @@ class Content { /** * The fields a post object may expose, in output order. * - * @since 1.1.0 + * @since x.x.x * @var string[] */ private static array $fields = array( @@ -69,7 +68,7 @@ class Content { /** * Default number of posts returned per page in query mode. * - * @since 1.1.0 + * @since x.x.x * @var int */ public const DEFAULT_PER_PAGE = 10; @@ -77,11 +76,22 @@ class Content { /** * Maximum number of posts returned per page in query mode. * - * @since 1.1.0 + * @since x.x.x * @var int */ public const MAX_PER_PAGE = 100; + /** + * Post types exposed through the Abilities API, computed once at registration. + * + * Plugin: cached so the input schema and the permission/execute callbacks derive from + * the exact same set, and the post type list is only walked once per request. + * + * @since x.x.x + * @var array|null + */ + private static ?array $exposed_post_types = null; + /** * Hooks the ability into the Abilities API. * @@ -91,7 +101,7 @@ class Content { * (priority 11) so it can override any core-provided copy, and registers the category * as a fallback in case core has not. * - * @since 1.1.0 + * @since x.x.x */ public static function init(): void { add_action( 'wp_abilities_api_categories_init', array( self::class, 'register_category' ), 11 ); @@ -104,7 +114,7 @@ public static function init(): void { * Plugin: this method has no equivalent in the core class; core relies on * wp_register_core_ability_categories() to register the `content` category. * - * @since 1.1.0 + * @since x.x.x */ public static function register_category(): void { if ( wp_has_ability_category( self::CATEGORY ) ) { @@ -114,8 +124,8 @@ public static function register_category(): void { wp_register_ability_category( self::CATEGORY, array( - 'label' => esc_html__( 'Content', 'ai' ), - 'description' => esc_html__( 'Abilities that retrieve or manage posts and other content.', 'ai' ), + 'label' => __( 'Content', 'ai' ), + 'description' => __( 'Abilities that retrieve or manage posts and other content.', 'ai' ), ) ); } @@ -125,7 +135,7 @@ public static function register_category(): void { * * Must run on the `wp_abilities_api_init` hook. * - * @since 1.1.0 + * @since x.x.x */ public static function register(): void { self::register_get_content(); @@ -141,7 +151,7 @@ public static function register(): void { /** * Registers the read-only `core/content` ability. * - * @since 1.1.0 + * @since x.x.x */ public static function register_get_content(): void { // Plugin: unregister any core-provided copy first so the plugin's version wins. @@ -149,14 +159,17 @@ public static function register_get_content(): void { wp_unregister_ability( 'core/content' ); } - $post_types = array_keys( self::get_exposed_post_types() ); + // Plugin: compute once; check_permission()/execute_get_content() reuse this set. + self::$exposed_post_types = self::get_exposed_post_types(); + + $post_types = array_keys( self::$exposed_post_types ); $statuses = self::get_available_statuses(); wp_register_ability( 'core/content', array( - 'label' => esc_html__( 'Get Content', 'ai' ), - 'description' => esc_html__( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.', 'ai' ), + 'label' => __( 'Get Content', 'ai' ), + 'description' => __( 'Retrieves one or more posts of a post type exposed to abilities. Fetch a single post by ID or by slug, or query multiple posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post.', 'ai' ), 'category' => self::CATEGORY, 'input_schema' => self::get_content_input_schema( $post_types, $statuses ), 'output_schema' => self::get_content_output_schema(), @@ -186,18 +199,18 @@ public static function register_get_content(): void { * `read_post` meta capability check in {@see self::execute_get_content()} is the * authoritative, row-level enforcement of author-scoped visibility. * - * @since 1.1.0 + * @since x.x.x * * @param mixed $input Optional. The ability input. Default empty array. * @return bool True if the request may proceed, false otherwise. */ public static function check_permission( $input = array() ): bool { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); if ( ! $post || ! isset( $exposed[ $post->post_type ] ) @@ -210,14 +223,14 @@ public static function check_permission( $input = array() ): bool { } // Query / slug mode requires an exposed post type. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return false; } $post_type_object = $exposed[ $post_type ]; - if ( ! current_user_can( $post_type_object->cap->read ?? 'read' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( ! current_user_can( self::capability( $post_type_object, 'read', 'read' ) ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. return false; } @@ -227,11 +240,11 @@ public static function check_permission( $input = array() ): bool { return true; } - if ( current_user_can( $post_type_object->cap->edit_posts ?? 'edit_posts' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( current_user_can( self::capability( $post_type_object, 'edit_posts', 'edit_posts' ) ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. return true; } - if ( current_user_can( $post_type_object->cap->read_private_posts ?? 'read_private_posts' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. + if ( current_user_can( self::capability( $post_type_object, 'read_private_posts', 'read_private_posts' ) ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object. foreach ( $statuses as $status ) { if ( 'private' !== $status && 'publish' !== $status ) { return false; @@ -243,22 +256,50 @@ public static function check_permission( $input = array() ): bool { return false; } + /** + * Resolves a capability name from a post type's capability object, with a fallback. + * + * @since x.x.x + * + * @param \WP_Post_Type $post_type_object The post type object. + * @param string $name Capability key on the post type's `cap` object. + * @param string $fallback Fallback capability name if unset or non-string. + * @return string The resolved capability name. + */ + protected static function capability( \WP_Post_Type $post_type_object, string $name, string $fallback ): string { + $capability = $post_type_object->cap->$name ?? $fallback; + + return is_string( $capability ) ? $capability : $fallback; + } + + /** + * Casts a raw input value to a non-negative integer. + * + * @since x.x.x + * + * @param mixed $value The raw input value. + * @return int The value as a non-negative integer, or 0 when not scalar. + */ + protected static function input_int( $value ): int { + return is_scalar( $value ) ? absint( $value ) : 0; + } + /** * Executes the `core/content` ability. * - * @since 1.1.0 + * @since x.x.x * * @param mixed $input Optional. The ability input. Default empty array. * @return array|\WP_Error A map with a `posts` list, or a WP_Error on failure. */ public static function execute_get_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); - $exposed = self::get_exposed_post_types(); + $exposed = self::$exposed_post_types ?? self::get_exposed_post_types(); $fields = self::normalize_fields( $input ); // Single-post mode (by ID). if ( ! empty( $input['id'] ) ) { - $post = get_post( (int) $input['id'] ); + $post = get_post( self::input_int( $input['id'] ) ); if ( ! $post || ! isset( $exposed[ $post->post_type ] ) @@ -276,13 +317,13 @@ public static function execute_get_content( $input = array() ) { } // Query / slug mode. - $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; + $post_type = isset( $input['post_type'] ) && is_string( $input['post_type'] ) ? $input['post_type'] : ''; if ( '' === $post_type || ! isset( $exposed[ $post_type ] ) ) { return self::not_found_error(); } $per_page = self::normalize_per_page( $input ); - $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1; + $page = isset( $input['page'] ) ? max( 1, self::input_int( $input['page'] ) ) : 1; $query_args = array( 'post_type' => $post_type, @@ -292,22 +333,25 @@ public static function execute_get_content( $input = array() ) { 'ignore_sticky_posts' => true, ); - if ( ! empty( $input['slug'] ) ) { - $query_args['name'] = sanitize_title( (string) $input['slug'] ); + if ( ! empty( $input['slug'] ) && is_string( $input['slug'] ) ) { + $query_args['name'] = sanitize_title( $input['slug'] ); } if ( ! empty( $input['author'] ) ) { - $query_args['author'] = (int) $input['author']; + $query_args['author'] = self::input_int( $input['author'] ); } if ( isset( $input['parent'] ) ) { - $query_args['post_parent'] = (int) $input['parent']; + $query_args['post_parent'] = self::input_int( $input['parent'] ); } $query = new WP_Query( $query_args ); $posts = array(); foreach ( $query->posts as $post ) { + if ( ! $post instanceof WP_Post ) { + continue; + } if ( ! current_user_can( 'read_post', $post->ID ) ) { continue; } @@ -324,13 +368,13 @@ public static function execute_get_content( $input = array() ) { /** * Normalizes the requested per-page value to the supported bounds. * - * @since 1.1.0 + * @since x.x.x * - * @param array $input The ability input. + * @param array $input The ability input. * @return int The clamped per-page value. */ protected static function normalize_per_page( array $input ): int { - $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : self::DEFAULT_PER_PAGE; + $per_page = isset( $input['per_page'] ) ? self::input_int( $input['per_page'] ) : self::DEFAULT_PER_PAGE; return max( 1, min( self::MAX_PER_PAGE, $per_page ) ); } @@ -338,7 +382,7 @@ protected static function normalize_per_page( array $input ): int { /** * Returns the post types exposed through the Abilities API, keyed by name. * - * @since 1.1.0 + * @since x.x.x * * @return array Exposed post type objects keyed by name. */ @@ -358,7 +402,7 @@ protected static function get_exposed_post_types(): array { /** * Returns the post statuses that may be requested through the ability. * - * @since 1.1.0 + * @since x.x.x * * @return string[] List of public, non-internal post status slugs. */ @@ -369,26 +413,28 @@ protected static function get_available_statuses(): array { /** * Normalizes the requested statuses to a non-empty, sanitized list defaulting to publish. * - * @since 1.1.0 + * @since x.x.x * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] Normalized list of post status slugs. */ protected static function normalize_statuses( array $input ): array { $statuses = $input['status'] ?? array( 'publish' ); - if ( ! is_array( $statuses ) || array() === $statuses ) { + if ( ! is_array( $statuses ) ) { return array( 'publish' ); } - return array_map( 'sanitize_key', $statuses ); + $statuses = array_values( array_filter( $statuses, 'is_string' ) ); + + return array() === $statuses ? array( 'publish' ) : array_map( 'sanitize_key', $statuses ); } /** * Normalizes the requested fields to the supported set, defaulting to all fields. * - * @since 1.1.0 + * @since x.x.x * - * @param array $input The ability input. + * @param array $input The ability input. * @return string[] List of requested field names. */ protected static function normalize_fields( array $input ): array { @@ -396,7 +442,8 @@ protected static function normalize_fields( array $input ): array { return self::$fields; } - $fields = array_intersect( self::$fields, array_map( 'strval', $input['fields'] ) ); + $requested = array_filter( $input['fields'], 'is_string' ); + $fields = array_intersect( self::$fields, $requested ); return array() === $fields ? self::$fields : array_values( $fields ); } @@ -404,7 +451,7 @@ protected static function normalize_fields( array $input ): array { /** * Builds the input schema for the `core/content` ability. * - * @since 1.1.0 + * @since x.x.x * * @param string[] $post_types Exposed post type names. * @param string[] $statuses Requestable post status slugs. @@ -413,7 +460,8 @@ protected static function normalize_fields( array $input ): array { protected static function get_content_input_schema( array $post_types, array $statuses ): array { return array( 'type' => 'object', - 'default' => array(), + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), // `post_type` is required unless a single post is requested by `id`. 'anyOf' => array( array( 'required' => array( 'id' ) ), @@ -423,16 +471,16 @@ protected static function get_content_input_schema( array $post_types, array $st 'post_type' => array( 'type' => 'string', 'enum' => $post_types, - 'description' => esc_html__( 'Post type to retrieve. Required unless `id` is provided.', 'ai' ), + 'description' => __( 'Post type to retrieve. Required unless `id` is provided.', 'ai' ), ), 'id' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => esc_html__( 'Retrieve a single post by ID. When provided, `post_type` is optional.', 'ai' ), + 'description' => __( 'Retrieve a single post by ID. When provided, `post_type` is optional.', 'ai' ), ), 'slug' => array( 'type' => 'string', - 'description' => esc_html__( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.', 'ai' ), + 'description' => __( 'Retrieve posts by slug. Requires `post_type`, as slugs are not unique across post types.', 'ai' ), ), 'status' => array( 'type' => 'array', @@ -442,17 +490,17 @@ protected static function get_content_input_schema( array $post_types, array $st 'type' => 'string', 'enum' => $statuses, ), - 'description' => esc_html__( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.', 'ai' ), + 'description' => __( 'Filter by one or more post statuses. Defaults to publish. Non-published statuses require the appropriate capabilities.', 'ai' ), ), 'author' => array( 'type' => 'integer', 'minimum' => 1, - 'description' => esc_html__( 'Filter by author user ID.', 'ai' ), + 'description' => __( 'Filter by author user ID.', 'ai' ), ), 'parent' => array( 'type' => 'integer', 'minimum' => 0, - 'description' => esc_html__( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.', 'ai' ), + 'description' => __( 'Filter by parent post ID, for hierarchical post types. Use 0 for top-level posts.', 'ai' ), ), 'fields' => array( 'type' => 'array', @@ -461,20 +509,20 @@ protected static function get_content_input_schema( array $post_types, array $st 'type' => 'string', 'enum' => self::$fields, ), - 'description' => esc_html__( 'Limit each returned post to these fields. If omitted, all supported fields are returned.', 'ai' ), + 'description' => __( 'Limit each returned post to these fields. If omitted, all supported fields are returned.', 'ai' ), ), 'page' => array( 'type' => 'integer', 'minimum' => 1, 'default' => 1, - 'description' => esc_html__( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.', 'ai' ), + 'description' => __( 'Page of results to return in query mode. Ignored when retrieving a single post by ID.', 'ai' ), ), 'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, 'default' => self::DEFAULT_PER_PAGE, - 'description' => esc_html__( 'Maximum number of posts to return per page in query mode.', 'ai' ), + 'description' => __( 'Maximum number of posts to return per page in query mode.', 'ai' ), ), ), 'additionalProperties' => false, @@ -484,7 +532,7 @@ protected static function get_content_input_schema( array $post_types, array $st /** * Builds the output schema for the `core/content` ability. * - * @since 1.1.0 + * @since x.x.x * * @return array The output JSON Schema. */ @@ -495,43 +543,43 @@ protected static function get_content_output_schema(): array { 'properties' => array( 'id' => array( 'type' => 'integer', - 'description' => esc_html__( 'The post ID.', 'ai' ), + 'description' => __( 'The post ID.', 'ai' ), ), 'type' => array( 'type' => 'string', - 'description' => esc_html__( 'The post type.', 'ai' ), + 'description' => __( 'The post type.', 'ai' ), ), 'status' => array( 'type' => 'string', - 'description' => esc_html__( 'The post status.', 'ai' ), + 'description' => __( 'The post status.', 'ai' ), ), 'date' => array( 'type' => 'string', - 'description' => esc_html__( 'The publication date, in ISO 8601 format (GMT).', 'ai' ), + 'description' => __( 'The publication date, in ISO 8601 format (GMT).', 'ai' ), ), 'modified' => array( 'type' => 'string', - 'description' => esc_html__( 'The last modified date, in ISO 8601 format (GMT).', 'ai' ), + 'description' => __( 'The last modified date, in ISO 8601 format (GMT).', 'ai' ), ), 'slug' => array( 'type' => 'string', - 'description' => esc_html__( 'The post slug.', 'ai' ), + 'description' => __( 'The post slug.', 'ai' ), ), 'link' => array( 'type' => 'string', - 'description' => esc_html__( 'The permalink URL.', 'ai' ), + 'description' => __( 'The permalink URL.', 'ai' ), ), 'title' => array( 'type' => 'string', - 'description' => esc_html__( 'The post title. Present when the post type supports titles.', 'ai' ), + 'description' => __( 'The post title. Present when the post type supports titles.', 'ai' ), ), 'excerpt' => array( 'type' => 'string', - 'description' => esc_html__( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.', 'ai' ), + 'description' => __( 'The post excerpt. Present when the post type supports excerpts. Empty when withheld for a password-protected post.', 'ai' ), ), 'raw_content' => array( 'type' => 'string', - 'description' => esc_html__( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.', 'ai' ), + 'description' => __( 'The raw, unfiltered post content (block markup). Present when the post type supports the editor. Empty when withheld for a password-protected post.', 'ai' ), ), 'author' => array( 'type' => 'object', @@ -539,18 +587,18 @@ protected static function get_content_output_schema(): array { 'properties' => array( 'id' => array( 'type' => 'integer', - 'description' => esc_html__( 'The author user ID.', 'ai' ), + 'description' => __( 'The author user ID.', 'ai' ), ), 'display_name' => array( 'type' => 'string', - 'description' => esc_html__( 'The author display name.', 'ai' ), + 'description' => __( 'The author display name.', 'ai' ), ), ), - 'description' => esc_html__( 'The post author. Present when the post type supports authors.', 'ai' ), + 'description' => __( 'The post author. Present when the post type supports authors.', 'ai' ), ), 'parent' => array( 'type' => 'integer', - 'description' => esc_html__( 'The parent post ID. Present for hierarchical post types.', 'ai' ), + 'description' => __( 'The parent post ID. Present for hierarchical post types.', 'ai' ), ), ), ); @@ -561,16 +609,16 @@ protected static function get_content_output_schema(): array { 'properties' => array( 'posts' => array( 'type' => 'array', - 'description' => esc_html__( 'The posts matching the request. A single-element list when requested by ID.', 'ai' ), + 'description' => __( 'The posts matching the request. A single-element list when requested by ID.', 'ai' ), 'items' => $post_schema, ), 'total' => array( 'type' => 'integer', - 'description' => esc_html__( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.', 'ai' ), + 'description' => __( 'Total number of posts matching the query, across all pages. Surfaced over REST as the X-WP-Total header.', 'ai' ), ), 'total_pages' => array( 'type' => 'integer', - 'description' => esc_html__( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.', 'ai' ), + 'description' => __( 'Total number of pages available. Surfaced over REST as the X-WP-TotalPages header.', 'ai' ), ), ), ); @@ -579,7 +627,7 @@ protected static function get_content_output_schema(): array { /** * Formats a post into the ability output shape. * - * @since 1.1.0 + * @since x.x.x * * @param \WP_Post $post The post object. * @param string[] $fields The requested field names. @@ -646,7 +694,7 @@ protected static function format_post( WP_Post $post, array $fields ): array { /** * Returns the post title with the protected/private prefixes stripped. * - * @since 1.1.0 + * @since x.x.x * * @param \WP_Post $post The post object. * @return string The post title. @@ -665,7 +713,7 @@ protected static function get_title( WP_Post $post ): string { /** * Returns the raw title format, used to strip protected/private title prefixes. * - * @since 1.1.0 + * @since x.x.x * * @return string The unprefixed title format. */ @@ -676,13 +724,14 @@ public static function return_raw_title_format(): string { /** * Formats a post date field as an ISO 8601 string in GMT. * - * @since 1.1.0 + * @since x.x.x * * @param \WP_Post $post The post object. * @param string $field Either 'date' or 'modified'. * @return string The ISO 8601 date, or an empty string if unavailable. */ protected static function format_gmt_date( WP_Post $post, string $field ): string { + $field = 'modified' === $field ? 'modified' : 'date'; $datetime = get_post_datetime( $post, $field, 'gmt' ); if ( $datetime ) { return $datetime->format( 'c' ); @@ -697,14 +746,14 @@ protected static function format_gmt_date( WP_Post $post, string $field ): strin /** * Builds the uniform not-found error. * - * @since 1.1.0 + * @since x.x.x * * @return \WP_Error The not-found error. */ protected static function not_found_error(): WP_Error { return new WP_Error( 'content_not_found', - esc_html__( 'The requested content was not found.', 'ai' ), + __( 'The requested content was not found.', 'ai' ), array( 'status' => 404 ) ); } diff --git a/includes/Abilities/Show_In_Abilities.php b/includes/Abilities/Show_In_Abilities.php index 9d66eeb9d..3ed59533f 100644 --- a/includes/Abilities/Show_In_Abilities.php +++ b/includes/Abilities/Show_In_Abilities.php @@ -4,7 +4,7 @@ * * @package WordPress\AI * - * @since 1.1.0 + * @since x.x.x */ declare( strict_types=1 ); @@ -27,14 +27,14 @@ * * @internal This class should not be used outside the plugin and there is no guarantee of backwards compatibility. * - * @since 1.1.0 + * @since x.x.x */ class Show_In_Abilities { /** * Registers the hooks that mark core objects as exposed to abilities. * - * @since 1.1.0 + * @since x.x.x */ public static function register(): void { add_filter( 'register_setting_args', array( self::class, 'mark_setting' ), 10, 4 ); @@ -54,7 +54,7 @@ public static function register(): void { * Respects an explicit `show_in_abilities` value already present on the setting (for * example once core ships it natively), only filling it in when absent. * - * @since 1.1.0 + * @since x.x.x * * @param array $args The setting registration arguments. * @param array $defaults The default registration arguments. @@ -79,7 +79,7 @@ public static function mark_setting( array $args, array $defaults, string $optio * already present on the post type (for example once core ships it natively), only * filling it in when absent. * - * @since 1.1.0 + * @since x.x.x * * @param array $args The post type registration arguments. * @param string $post_type The post type key. @@ -103,7 +103,7 @@ public static function mark_post_type( array $args, string $post_type ): array { * post type objects directly so the polyfill works regardless of when it runs. * {@see WP_Post_Type} allows dynamic properties, so this is safe on stock WordPress. * - * @since 1.1.0 + * @since x.x.x */ public static function mark_registered_post_types(): void { foreach ( self::post_types_map() as $post_type => $show ) { @@ -112,7 +112,7 @@ public static function mark_registered_post_types(): void { continue; } - $object->show_in_abilities = $show; + $object->show_in_abilities = $show; // @phpstan-ignore property.notFound (WP_Post_Type permits dynamic properties; core adds show_in_abilities in 7.1.) } } @@ -123,7 +123,7 @@ public static function mark_registered_post_types(): void { * reserved for enabling specific operations in the future. This matches the set * marked natively by the core `core/content` implementation (`post` and `page`). * - * @since 1.1.0 + * @since x.x.x * * @return array> Post types map keyed by post type key. */ @@ -141,7 +141,7 @@ public static function post_types_map(): array { * optional `name` and `schema` keys (mirroring the `show_in_rest` shape). This matches * the set marked natively by the core `core/settings` implementation. * - * @since 1.1.0 + * @since x.x.x * * @return array> Settings map keyed by option name. */ diff --git a/tests/Integration/Includes/Abilities/Content/ContentTest.php b/tests/Integration/Includes/Abilities/Content/ContentTest.php index 145083d92..e6fed3b3d 100644 --- a/tests/Integration/Includes/Abilities/Content/ContentTest.php +++ b/tests/Integration/Includes/Abilities/Content/ContentTest.php @@ -14,14 +14,14 @@ /** * Content ability test case. * - * @since 1.1.0 + * @since x.x.x */ class ContentTest extends WP_UnitTestCase { /** * Set up test case. * - * @since 1.1.0 + * @since x.x.x */ public function setUp(): void { parent::setUp(); @@ -35,7 +35,7 @@ public function setUp(): void { /** * Tear down test case. * - * @since 1.1.0 + * @since x.x.x */ public function tearDown(): void { if ( wp_has_ability( 'core/content' ) ) { @@ -63,7 +63,7 @@ public function tearDown(): void { /** * Ensures the `content` ability category exists for the ability to attach to. * - * @since 1.1.0 + * @since x.x.x */ private function ensure_content_category(): void { if ( wp_has_ability_category( 'content' ) ) { @@ -88,7 +88,7 @@ private function ensure_content_category(): void { /** * Registers the plugin's core/content ability inside a faked init action. * - * @since 1.1.0 + * @since x.x.x */ private function register_ability(): void { global $wp_current_filter; @@ -115,7 +115,7 @@ private function login_as( string $role ): int { /** * The ability is registered in the `content` category and flagged read-only. * - * @since 1.1.0 + * @since x.x.x */ public function test_registers_core_content_ability(): void { $this->register_ability(); @@ -133,7 +133,7 @@ public function test_registers_core_content_ability(): void { /** * When core already provides core/content, the plugin's version replaces it. * - * @since 1.1.0 + * @since x.x.x */ public function test_override_replaces_existing_core_content(): void { global $wp_current_filter; @@ -165,7 +165,7 @@ public function test_override_replaces_existing_core_content(): void { /** * The input schema requires either `id` or `post_type` and exposes only marked types. * - * @since 1.1.0 + * @since x.x.x */ public function test_input_schema_requires_id_or_post_type(): void { $this->register_ability(); @@ -188,7 +188,7 @@ public function test_input_schema_requires_id_or_post_type(): void { /** * A published post can be fetched by ID. * - * @since 1.1.0 + * @since x.x.x */ public function test_get_single_published_post_by_id(): void { $this->login_as( 'administrator' ); @@ -214,7 +214,7 @@ public function test_get_single_published_post_by_id(): void { /** * Query mode returns only published posts by default. * - * @since 1.1.0 + * @since x.x.x */ public function test_query_returns_only_published_by_default(): void { $this->login_as( 'administrator' ); @@ -233,7 +233,7 @@ public function test_query_returns_only_published_by_default(): void { /** * Querying by slug without a post type is rejected by the input schema. * - * @since 1.1.0 + * @since x.x.x */ public function test_query_by_slug_requires_post_type(): void { $this->login_as( 'administrator' ); @@ -248,7 +248,7 @@ public function test_query_by_slug_requires_post_type(): void { /** * The `fields` filter limits the returned keys. * - * @since 1.1.0 + * @since x.x.x */ public function test_fields_filter_limits_returned_keys(): void { $this->login_as( 'administrator' ); @@ -269,7 +269,7 @@ public function test_fields_filter_limits_returned_keys(): void { /** * Logged-out users cannot run the ability. * - * @since 1.1.0 + * @since x.x.x */ public function test_logged_out_user_is_denied(): void { wp_set_current_user( 0 ); @@ -284,7 +284,7 @@ public function test_logged_out_user_is_denied(): void { /** * Subscribers cannot request draft posts. * - * @since 1.1.0 + * @since x.x.x */ public function test_subscriber_cannot_request_draft_status(): void { $this->login_as( 'subscriber' ); @@ -304,7 +304,7 @@ public function test_subscriber_cannot_request_draft_status(): void { /** * An author can pass the draft gate but only sees their own drafts. * - * @since 1.1.0 + * @since x.x.x */ public function test_author_cannot_see_other_authors_drafts(): void { $author_a = self::factory()->user->create( array( 'role' => 'author' ) ); @@ -341,7 +341,7 @@ public function test_author_cannot_see_other_authors_drafts(): void { /** * Password-protected content is withheld from users who cannot edit the post. * - * @since 1.1.0 + * @since x.x.x */ public function test_password_protected_content_withheld_from_non_editor(): void { $post_id = self::factory()->post->create( @@ -368,7 +368,7 @@ public function test_password_protected_content_withheld_from_non_editor(): void /** * Query mode paginates with `page`/`per_page` and reports totals. * - * @since 1.1.0 + * @since x.x.x */ public function test_query_paginates_and_reports_totals(): void { $this->login_as( 'administrator' ); @@ -403,7 +403,7 @@ public function test_query_paginates_and_reports_totals(): void { /** * A single post fetched by ID still reports pagination totals of one. * - * @since 1.1.0 + * @since x.x.x */ public function test_single_post_reports_totals(): void { $this->login_as( 'administrator' ); diff --git a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php index 392330f5a..2c05ae18a 100644 --- a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php +++ b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php @@ -13,14 +13,14 @@ /** * Show_In_Abilities test case. * - * @since 1.1.0 + * @since x.x.x */ class Show_In_AbilitiesTest extends WP_UnitTestCase { /** * Option names registered during a test, cleaned up on tear down. * - * @since 1.1.0 + * @since x.x.x * * @var array */ @@ -29,7 +29,7 @@ class Show_In_AbilitiesTest extends WP_UnitTestCase { /** * Set up test case. * - * @since 1.1.0 + * @since x.x.x */ public function setUp(): void { parent::setUp(); @@ -40,7 +40,7 @@ public function setUp(): void { /** * Tear down test case. * - * @since 1.1.0 + * @since x.x.x */ public function tearDown(): void { remove_filter( 'register_setting_args', array( Show_In_Abilities::class, 'mark_setting' ), 10 ); @@ -67,7 +67,7 @@ public function tearDown(): void { /** * Registers a setting and tracks it for cleanup. * - * @since 1.1.0 + * @since x.x.x * * @param string $group The settings group. * @param string $option The option name. @@ -81,7 +81,7 @@ private function register_setting( string $group, string $option, array $args ): /** * A curated setting is flagged with `show_in_abilities => true`. * - * @since 1.1.0 + * @since x.x.x */ public function test_marks_curated_boolean_setting(): void { $this->register_setting( 'general', 'blogname', array( 'type' => 'string' ) ); @@ -94,7 +94,7 @@ public function test_marks_curated_boolean_setting(): void { /** * A curated setting that maps to an array value receives that array verbatim. * - * @since 1.1.0 + * @since x.x.x */ public function test_marks_curated_array_setting(): void { $this->register_setting( 'discussion', 'default_comment_status', array( 'type' => 'string' ) ); @@ -110,7 +110,7 @@ public function test_marks_curated_array_setting(): void { /** * A setting that is not in the curated map is left untouched. * - * @since 1.1.0 + * @since x.x.x */ public function test_does_not_mark_uncurated_setting(): void { $this->register_setting( 'general', 'wpai_not_curated_option', array( 'type' => 'string' ) ); @@ -123,7 +123,7 @@ public function test_does_not_mark_uncurated_setting(): void { /** * An explicit `show_in_abilities` value already on the setting is preserved. * - * @since 1.1.0 + * @since x.x.x */ public function test_respects_existing_value(): void { $this->register_setting( @@ -143,7 +143,7 @@ public function test_respects_existing_value(): void { /** * Curated core post types are marked directly, since they register before the filter. * - * @since 1.1.0 + * @since x.x.x */ public function test_marks_curated_registered_post_types(): void { // Show_In_Abilities::register() ran in setUp and patches existing post types. @@ -154,7 +154,7 @@ public function test_marks_curated_registered_post_types(): void { /** * The post type args filter marks a curated post type when it is registered. * - * @since 1.1.0 + * @since x.x.x */ public function test_filter_marks_curated_post_type(): void { $args = Show_In_Abilities::mark_post_type( array(), 'page' ); @@ -165,7 +165,7 @@ public function test_filter_marks_curated_post_type(): void { /** * The post type args filter leaves uncurated post types untouched. * - * @since 1.1.0 + * @since x.x.x */ public function test_filter_skips_uncurated_post_type(): void { $args = Show_In_Abilities::mark_post_type( array(), 'wpai_not_curated_cpt' ); @@ -176,7 +176,7 @@ public function test_filter_skips_uncurated_post_type(): void { /** * An explicit `show_in_abilities` value already on the post type is preserved. * - * @since 1.1.0 + * @since x.x.x */ public function test_filter_respects_existing_post_type_value(): void { $args = Show_In_Abilities::mark_post_type(