From 9f64920f0c55ded981311a5babc45ee929866d0c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 10:50:00 +0100 Subject: [PATCH 1/4] Add a core/manage-settings ability to update settings Implements the write-oriented core/manage-settings ability that core's WP_Settings_Abilities reserves but does not yet ship. It reuses the exposed-settings snapshot built for core/settings, so every setting flagged with show_in_abilities is both readable (core/settings) and writable (core/manage-settings), and the input/output schemas reuse the same per-setting schemas. The Abilities API validates the input against those schemas (with additionalProperties disabled) before execution, so an invalid or unknown value aborts the whole call before any option is written -- matching the all-or-nothing behavior of the core REST settings controller. Each accepted value is sanitized against its schema, stored, then read back and cast for the response. --- includes/Abilities/Settings/Settings.php | 130 +++++++++++++++++++++-- 1 file changed, 119 insertions(+), 11 deletions(-) diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php index a6be43b4..b941f634 100644 --- a/includes/Abilities/Settings/Settings.php +++ b/includes/Abilities/Settings/Settings.php @@ -17,10 +17,13 @@ /** * Class - Settings * - * Registers the read-only `core/settings` ability, which returns WordPress settings as a - * flat map of setting name to value. Only settings flagged with `show_in_abilities` are - * exposed. It is structured to also back a future write-oriented `core/manage-settings` - * ability via the shared helpers (get_exposed_settings(), value_schema(), cast_value()). + * Registers the read-only `core/settings` ability and the write-oriented `core/manage-settings` + * ability. Both operate on the same settings — those flagged with `show_in_abilities` — as a flat + * map of setting name to value: `core/settings` reads them and `core/manage-settings` updates them, + * sharing the helpers get_exposed_settings(), value_schema() and cast_value(). + * + * Plugin: WordPress core's WP_Settings_Abilities currently reserves `core/manage-settings` but does + * not implement it; the plugin ships the write ability ahead of core. * * This class is kept almost identical to the WordPress core class `WP_Settings_Abilities` * so the two implementations stay in sync. Differences from the core class are marked with @@ -74,13 +77,7 @@ public function init(): void { */ public function register(): void { $this->register_get_settings(); - - /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings(), value_schema(), cast_value()): - * - * $this->register_manage_settings(); - */ + $this->register_manage_settings(); } /** @@ -136,6 +133,70 @@ private function register_get_settings(): void { ); } + /** + * Registers the write-oriented `core/manage-settings` ability. + * + * Accepts a map of exposed setting name to its new value and stores each one, returning the + * updated values. Every setting exposed to abilities is writable: a truthy `show_in_abilities` + * flag grants both read (via `core/settings`) and write (via this ability), so the input and + * output schemas reuse the same per-setting schemas as the read ability. + * + * The Abilities API validates the input against those schemas (with `additionalProperties` + * disabled) before {@see execute_manage_settings()} runs, so an invalid or unknown value aborts + * the whole call before any option is written — matching the all-or-nothing behavior of the core + * REST settings controller. + * + * Plugin: WordPress core reserves this ability in WP_Settings_Abilities::register() but does not + * yet implement it. The plugin implements it here, reusing the exposed-settings snapshot that + * register_get_settings() computed. + * + * @since x.x.x + */ + private function register_manage_settings(): void { + // Plugin: unregister any core-provided copy first so the plugin's version wins. + if ( wp_has_ability( 'core/manage-settings' ) ) { + wp_unregister_ability( 'core/manage-settings' ); + } + + $settings = (array) $this->exposed_settings; + $properties = array(); + foreach ( $settings as $exposed_name => $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + } + + wp_register_ability( + 'core/manage-settings', + array( + 'label' => __( 'Manage Settings', 'ai' ), + 'description' => __( 'Updates one or more WordPress settings exposed to abilities. Accepts a map of setting name to its new value and returns the updated values.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to the new value to store. At least one setting is required.', 'ai' ), + 'properties' => $properties, + 'minProperties' => 1, + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of each updated setting name to its new value.', 'ai' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( $this, 'execute_manage_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + /** * Executes the `core/settings` ability. * @@ -175,6 +236,53 @@ public function execute_get_settings( $input = array() ): array { return $result; } + /** + * Executes the `core/manage-settings` ability. + * + * The Abilities API validates the input against the registered input schema (each setting's own + * value schema, with `additionalProperties` disabled) before this runs, so every value reaching + * here is known and valid; an invalid value aborts the call before any option is written. Each + * value is sanitized against its schema and stored, then read back and cast for the response. + * + * @since x.x.x + * + * @param mixed $input The ability input: a map of exposed setting name to its new value. + * @return array Map of each updated setting name to its stored value. + */ + public function execute_manage_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = $this->exposed_settings; + if ( null === $settings ) { + // The cache is populated in register_get_settings() before the ability is + // registered, so this is unreachable in practice; bail defensively otherwise. + return array(); + } + + $result = array(); + foreach ( $input as $exposed_name => $value ) { + if ( ! is_string( $exposed_name ) || ! isset( $settings[ $exposed_name ] ) ) { + // `additionalProperties: false` already rejects unknown keys upstream; guard defensively. + continue; + } + + $setting = $settings[ $exposed_name ]; + + // Sanitize against the declared schema before storing; update_option() additionally + // runs the setting's own registered sanitize_callback. + $value = rest_sanitize_value_from_schema( $value, $setting['schema'], $exposed_name ); + + update_option( $setting['option'], $value ); + + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; + $stored = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = $this->cast_value( $stored, $type ); + } + + return $result; + } + /** * Checks whether the current user may use the settings abilities. * From 162d8c4dd048afa6e2c2e8053b852a41a15ac096 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 13:10:00 +0100 Subject: [PATCH 2/4] Test the core/manage-settings ability Extends the settings ability test suite with coverage for the write ability: registration and writable annotations, an input schema that mirrors the exposed settings with additionalProperties disabled, updating and returning correctly typed values, all-or-nothing aborting on an invalid value (the valid sibling must not persist), rejection of unknown keys and empty input, the manage_options permission gate, and writing a setting registered by another plugin. --- .../Abilities/Settings/SettingsTest.php | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php index 1ced0753..af83b6fd 100644 --- a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php +++ b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php @@ -67,6 +67,9 @@ public function tearDown(): void { if ( wp_has_ability( 'core/settings' ) ) { wp_unregister_ability( 'core/settings' ); } + if ( wp_has_ability( 'core/manage-settings' ) ) { + wp_unregister_ability( 'core/manage-settings' ); + } remove_filter( 'register_setting_args', array( $this->show_in_abilities, 'mark_setting' ), 10 ); unregister_setting( 'general', 'core_settings_ability_test_option' ); @@ -308,4 +311,161 @@ public function test_core_settings_exposes_a_custom_registered_setting(): void { $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); } + + /** + * The manage ability is registered in the `site` category and flagged writable. + * + * @since x.x.x + */ + public function test_core_manage_settings_ability_is_registered(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/manage-settings' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertFalse( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * Every setting exposed for reading is writable: the input schema mirrors the exposed set + * and disallows unknown properties. + * + * @since x.x.x + */ + public function test_core_manage_settings_input_schema_mirrors_exposed_settings(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/manage-settings' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertFalse( $schema['additionalProperties'] ); + $this->assertSame( 1, $schema['minProperties'] ); + $this->assertArrayHasKey( 'blogname', $schema['properties'] ); + $this->assertArrayHasKey( 'posts_per_page', $schema['properties'] ); + } + + /** + * The ability stores each provided setting and returns the updated, correctly typed values. + * + * @since x.x.x + */ + public function test_core_manage_settings_updates_and_returns_values(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ) + ); + + $this->assertSame( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ), + $result + ); + // Persisted to the database. + $this->assertSame( 'Renamed Site', get_option( 'blogname' ) ); + $this->assertSame( 9, (int) get_option( 'posts_per_page' ) ); + } + + /** + * An invalid value aborts the whole call before any option is written (all-or-nothing). + * + * @since x.x.x + */ + public function test_core_manage_settings_is_atomic_on_invalid_value(): void { + $this->become_admin(); + $this->register_ability(); + + update_option( 'blogname', 'Original Name' ); + + // `default_ping_status` is constrained to the enum open|closed; `sometimes` is invalid, so + // the whole call must fail and the valid sibling value must not be written. + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Should Not Persist', + 'default_ping_status' => 'sometimes', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * Unknown setting names are rejected by `additionalProperties: false`. + * + * @since x.x.x + */ + public function test_core_manage_settings_rejects_unknown_setting(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'not_a_registered_setting' => 'value' ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Empty input is rejected: at least one setting must be provided. + * + * @since x.x.x + */ + public function test_core_manage_settings_rejects_empty_input(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Users without `manage_options` cannot run the manage ability, and nothing is written. + * + * @since x.x.x + */ + public function test_core_manage_settings_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->register_ability(); + + update_option( 'blogname', 'Original Name' ); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array( 'blogname' => 'Nope' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is writable. + * + * @since x.x.x + */ + public function test_core_manage_settings_updates_a_custom_registered_setting(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'core_settings_ability_test_option' => 100 ) + ); + + $this->assertSame( array( 'core_settings_ability_test_option' => 100 ), $result ); + $this->assertSame( 100, (int) get_option( 'core_settings_ability_test_option' ) ); + } } From 64132037a5cd08c734484d37a646ac636eff2db7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 15:35:00 +0100 Subject: [PATCH 3/4] Add a client-side e2e spec for core/manage-settings Runs the write ability through the browser Abilities API, mirroring the core/settings spec: updates blogname and posts_per_page and asserts the echoed values, reads them back through core/settings to confirm they persisted, rejects an unknown setting, and writes a setting registered by another active plugin. Each test restores the original values when done. --- .../abilities/core-manage-settings.spec.js | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/e2e/specs/abilities/core-manage-settings.spec.js diff --git a/tests/e2e/specs/abilities/core-manage-settings.spec.js b/tests/e2e/specs/abilities/core-manage-settings.spec.js new file mode 100644 index 00000000..1569ccdc --- /dev/null +++ b/tests/e2e/specs/abilities/core-manage-settings.spec.js @@ -0,0 +1,141 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +/** + * Runs an 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 {string} ability The ability name. + * @param {Object} input The ability input. + * @return {Promise} `{ ok: true, result }` or `{ ok: false, code }`. + */ +async function runAbility( page, ability, input ) { + return page.evaluate( + async ( { abilityName, abilityInput } ) => { + const { ready } = await import( '@wordpress/core-abilities' ); + if ( ready ) { + await ready; + } + + const { executeAbility } = await import( '@wordpress/abilities' ); + + try { + const result = await executeAbility( + abilityName, + abilityInput + ); + return { ok: true, result }; + } catch ( e ) { + return { ok: false, code: e && e.code ? e.code : null }; + } + }, + { abilityName: ability, abilityInput: input } + ); +} + +test.describe( 'core/manage-settings 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/manage-settings ability test', + } ); + } ); + + test( 'updates settings and persists the new values', async ( { + page, + } ) => { + // Capture the originals so the test restores site state when it is done. + const before = await runAbility( page, 'core/settings', { + fields: [ 'blogname', 'posts_per_page' ], + } ); + expect( before.ok ).toBe( true ); + const original = before.result; + + const updated = await runAbility( page, 'core/manage-settings', { + blogname: 'Managed Settings E2E', + posts_per_page: 13, + } ); + + expect( updated.ok ).toBe( true ); + // The write ability echoes back the stored, correctly typed values. + expect( updated.result ).toEqual( { + blogname: 'Managed Settings E2E', + posts_per_page: 13, + } ); + + // Read back through the read ability to confirm the values were persisted. + const after = await runAbility( page, 'core/settings', { + fields: [ 'blogname', 'posts_per_page' ], + } ); + expect( after.ok ).toBe( true ); + expect( after.result.blogname ).toBe( 'Managed Settings E2E' ); + expect( after.result.posts_per_page ).toBe( 13 ); + + // Restore the original values. + await runAbility( page, 'core/manage-settings', original ); + } ); + + test( 'rejects an unknown setting', async ( { page } ) => { + // `additionalProperties: false` makes an unregistered key invalid input. + const outcome = await runAbility( page, 'core/manage-settings', { + not_a_registered_setting: 'value', + } ); + + expect( outcome.ok ).toBe( false ); + } ); + + test( 'writes a setting registered by another active plugin', async ( { + page, + } ) => { + // Registered by the `e2e-testing` plugin (mapped in .wp-env.test.json) with + // `show_in_abilities`, so it is both readable and writable. + const before = await runAbility( page, 'core/settings', { + fields: [ 'ai_e2e_sample_setting' ], + } ); + expect( before.ok ).toBe( true ); + const original = before.result; + + const updated = await runAbility( page, 'core/manage-settings', { + ai_e2e_sample_setting: 'managed-value', + } ); + expect( updated.ok ).toBe( true ); + expect( updated.result ).toEqual( { + ai_e2e_sample_setting: 'managed-value', + } ); + + const after = await runAbility( page, 'core/settings', { + fields: [ 'ai_e2e_sample_setting' ], + } ); + expect( after.result.ai_e2e_sample_setting ).toBe( 'managed-value' ); + + // Restore the original value. + await runAbility( page, 'core/manage-settings', original ); + } ); +} ); From 68d5383052b097c39e5ea8390aa82d7fd648011d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 23 Jun 2026 16:06:39 +0100 Subject: [PATCH 4/4] Trim redundant comments on core/manage-settings Drops three comments that restated information available elsewhere: the class-docblock "Plugin:" note (duplicated by the method docblock), the register_manage_settings() paragraph describing atomicity (already on execute_manage_settings()), and an e2e comment restating its own assertion. --- includes/Abilities/Settings/Settings.php | 19 ++++--------------- .../abilities/core-manage-settings.spec.js | 1 - 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php index b941f634..4eff0fe2 100644 --- a/includes/Abilities/Settings/Settings.php +++ b/includes/Abilities/Settings/Settings.php @@ -22,9 +22,6 @@ * map of setting name to value: `core/settings` reads them and `core/manage-settings` updates them, * sharing the helpers get_exposed_settings(), value_schema() and cast_value(). * - * Plugin: WordPress core's WP_Settings_Abilities currently reserves `core/manage-settings` but does - * not implement it; the plugin ships the write ability ahead of core. - * * This class is kept almost identical to the WordPress core class `WP_Settings_Abilities` * so the two implementations stay in sync. Differences from the core class are marked with * `// Plugin:` comments. Additionally, all user-facing strings use the 'ai' text domain. @@ -136,19 +133,11 @@ private function register_get_settings(): void { /** * Registers the write-oriented `core/manage-settings` ability. * - * Accepts a map of exposed setting name to its new value and stores each one, returning the - * updated values. Every setting exposed to abilities is writable: a truthy `show_in_abilities` - * flag grants both read (via `core/settings`) and write (via this ability), so the input and - * output schemas reuse the same per-setting schemas as the read ability. - * - * The Abilities API validates the input against those schemas (with `additionalProperties` - * disabled) before {@see execute_manage_settings()} runs, so an invalid or unknown value aborts - * the whole call before any option is written — matching the all-or-nothing behavior of the core - * REST settings controller. + * The input and output schemas reuse each exposed setting's own schema, so every setting + * readable via `core/settings` is also writable through this ability. * - * Plugin: WordPress core reserves this ability in WP_Settings_Abilities::register() but does not - * yet implement it. The plugin implements it here, reusing the exposed-settings snapshot that - * register_get_settings() computed. + * Plugin: core reserves this ability in WP_Settings_Abilities::register() but does not yet + * implement it; the plugin ships it here, reusing the snapshot register_get_settings() computed. * * @since x.x.x */ diff --git a/tests/e2e/specs/abilities/core-manage-settings.spec.js b/tests/e2e/specs/abilities/core-manage-settings.spec.js index 1569ccdc..634cd83a 100644 --- a/tests/e2e/specs/abilities/core-manage-settings.spec.js +++ b/tests/e2e/specs/abilities/core-manage-settings.spec.js @@ -84,7 +84,6 @@ test.describe( 'core/manage-settings ability (client-side Abilities API)', () => } ); expect( updated.ok ).toBe( true ); - // The write ability echoes back the stored, correctly typed values. expect( updated.result ).toEqual( { blogname: 'Managed Settings E2E', posts_per_page: 13,