From 2a7e6227dc9ad32d2b570350447e514f32a8a781 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 12:01:10 +0100 Subject: [PATCH 1/7] Connectors: Add `wp_connectors_settings` filter for extensibility. Introduce a `wp_connectors_settings` filter in `_wp_connectors_get_connector_settings()` so plugins can add, modify, or remove connectors on the Connectors screen. The filter runs after built-in and AI Client registry providers are collected but before `setting_name` is auto-generated for API-key connectors. Also tighten the `setting_name` generation to only apply when `type === 'ai_provider'`, preventing non-AI connectors from receiving an incorrect `connectors_ai_*` setting name. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 21 ++- .../wpConnectorsGetConnectorSettings.php | 156 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 0da60353705c2..cd284128d8265 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -202,9 +202,26 @@ function _wp_connectors_get_connector_settings(): array { ksort( $connectors ); - // Add setting_name for connectors that use API key authentication. + /** + * Filters the registered connector settings. + * + * Allows plugins to add, modify, or remove connectors displayed + * on the Connectors screen. Runs after built-in and AI Client + * registry providers are collected, but before `setting_name` is + * generated for API-key connectors. + * + * @since 7.0.0 + * + * @param array $connectors Connector settings keyed by connector ID. + * Each entry is an array with keys: name, description, + * type, authentication (with method, and optionally + * credentials_url), and optionally plugin. + */ + $connectors = apply_filters( 'wp_connectors_settings', $connectors ); + + // Add setting_name for AI provider connectors that use API key authentication. foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { + if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; } } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index f2d0aa68ee0e1..bfa0cfe233449 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -115,4 +115,160 @@ public function test_includes_registered_provider_from_registry() { $this->assertNull( $mock['authentication']['credentials_url'] ); $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); } + + /** + * @ticket 64730 + */ + public function test_filter_can_add_new_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['my_email_service'] = array( + 'name' => 'My Email Service', + 'description' => 'Send transactional emails.', + 'type' => 'email_service', + 'authentication' => array( 'method' => 'none' ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'my_email_service', $connectors ); + $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); + $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); + $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_can_modify_existing_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['google']['description'] = 'Custom description for Google.'; + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_can_remove_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + unset( $connectors['openai'] ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayNotHasKey( 'openai', $connectors ); + // Other connectors remain. + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_added_api_key_connector_gets_setting_name() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['custom_ai'] = array( + 'name' => 'Custom AI', + 'description' => 'A custom AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'custom_ai', $connectors ); + $this->assertSame( + 'connectors_ai_custom_ai_api_key', + $connectors['custom_ai']['authentication']['setting_name'], + 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' + ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['my_crm'] = array( + 'name' => 'My CRM', + 'description' => 'CRM integration.', + 'type' => 'crm', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/crm-keys', + ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'my_crm', $connectors ); + $this->assertArrayNotHasKey( + 'setting_name', + $connectors['my_crm']['authentication'], + 'Non-AI connectors should not receive an auto-generated setting_name.' + ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_receives_all_default_connectors() { + $received = null; + + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) use ( &$received ) { + $received = $connectors; + return $connectors; + } + ); + + _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'google', $received ); + $this->assertArrayHasKey( 'openai', $received ); + $this->assertArrayHasKey( 'anthropic', $received ); + $this->assertArrayHasKey( 'mock_connectors_test', $received ); + + remove_all_filters( 'wp_connectors_settings' ); + } } From 2b6427bc7d825af55be497ef8c3f61772fa0e6d5 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 13:27:41 +0100 Subject: [PATCH 2/7] Connectors: Improve `wp_connectors_settings` filter PHPDoc and test cleanup. Use structured PHPDoc for the `$connectors` param in `apply_filters()` to mirror the function's return documentation. In tests, use closure references for filter removal before assertions to prevent leaking to other tests. Follow-up to [555bb8a833]. See #64791. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 25 ++- .../wpConnectorsGetConnectorSettings.php | 144 ++++++++---------- 2 files changed, 84 insertions(+), 85 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index cd284128d8265..e4f2327b4794e 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -212,10 +212,27 @@ function _wp_connectors_get_connector_settings(): array { * * @since 7.0.0 * - * @param array $connectors Connector settings keyed by connector ID. - * Each entry is an array with keys: name, description, - * type, authentication (with method, and optionally - * credentials_url), and optionally plugin. + * @param array $connectors { + * Connector settings keyed by connector ID. + * + * @type array ...$0 { + * Data for a single connector. + * + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url. When 'none', only method is present. + * + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * } + * } */ $connectors = apply_filters( 'wp_connectors_settings', $connectors ); diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index bfa0cfe233449..e2bc42c2ebdeb 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -117,94 +117,84 @@ public function test_includes_registered_provider_from_registry() { } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_add_new_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['my_email_service'] = array( - 'name' => 'My Email Service', - 'description' => 'Send transactional emails.', - 'type' => 'email_service', - 'authentication' => array( 'method' => 'none' ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['my_email_service'] = array( + 'name' => 'My Email Service', + 'description' => 'Send transactional emails.', + 'type' => 'email_service', + 'authentication' => array( 'method' => 'none' ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'my_email_service', $connectors ); $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_modify_existing_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['google']['description'] = 'Custom description for Google.'; - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['google']['description'] = 'Custom description for Google.'; + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_remove_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - unset( $connectors['openai'] ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + unset( $connectors['openai'] ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayNotHasKey( 'openai', $connectors ); // Other connectors remain. $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'anthropic', $connectors ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_added_api_key_connector_gets_setting_name() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['custom_ai'] = array( - 'name' => 'Custom AI', - 'description' => 'A custom AI provider.', - 'type' => 'ai_provider', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/keys', - ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['custom_ai'] = array( + 'name' => 'Custom AI', + 'description' => 'A custom AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'custom_ai', $connectors ); $this->assertSame( @@ -212,31 +202,28 @@ static function ( $connectors ) { $connectors['custom_ai']['authentication']['setting_name'], 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['my_crm'] = array( - 'name' => 'My CRM', - 'description' => 'CRM integration.', - 'type' => 'crm', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/crm-keys', - ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['my_crm'] = array( + 'name' => 'My CRM', + 'description' => 'CRM integration.', + 'type' => 'crm', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/crm-keys', + ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'my_crm', $connectors ); $this->assertArrayNotHasKey( @@ -244,31 +231,26 @@ static function ( $connectors ) { $connectors['my_crm']['authentication'], 'Non-AI connectors should not receive an auto-generated setting_name.' ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_receives_all_default_connectors() { $received = null; - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) use ( &$received ) { - $received = $connectors; - return $connectors; - } - ); + $callback = static function ( $connectors ) use ( &$received ) { + $received = $connectors; + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'google', $received ); $this->assertArrayHasKey( 'openai', $received ); $this->assertArrayHasKey( 'anthropic', $received ); $this->assertArrayHasKey( 'mock_connectors_test', $received ); - - remove_all_filters( 'wp_connectors_settings' ); } } From bd4090d3ce549e818d38a063f111ae6e883051ce Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 13:44:47 +0100 Subject: [PATCH 3/7] Connectors: Add registry guards before registering settings and passing keys. Skip `register_setting` in `_wp_register_default_connector_settings()` when the AI provider is not in the registry, preventing REST-exposed settings that silently reject values. Reorder the `hasProvider` check in `_wp_connectors_pass_default_keys_to_ai_client()` to run before reading the option. Update the REST settings test to reflect that connector settings are only registered when their provider is active. Follow-up to [555bb8a833], [bd6c4c87ae]. See #64791. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 13 +++++++++++- .../rest-api/rest-settings-controller.php | 4 ---- tests/qunit/fixtures/wp-api-generated.js | 21 ------------------- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index e4f2327b4794e..9b25f488d14d6 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -316,12 +316,19 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { + $registry = AiClient::defaultRegistry(); + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } + // Skip registering the setting if the provider is not in the registry. + if ( ! $registry->hasProvider( $connector_id ) ) { + continue; + } + $setting_name = $auth['setting_name']; register_setting( 'connectors', @@ -375,8 +382,12 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } + if ( ! $registry->hasProvider( $connector_id ) ) { + continue; + } + $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( '' === $api_key ) { continue; } diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index ef9e72e6a6724..dd79885d2b16d 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,10 +120,6 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', - // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. - 'connectors_ai_anthropic_api_key', - 'connectors_ai_google_api_key', - 'connectors_ai_openai_api_key', ); if ( ! is_multisite() ) { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index a8e8c6280600c..b9a143d12d95c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11066,24 +11066,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_google_api_key": { - "title": "Google API Key", - "description": "API key for the Google AI provider.", - "type": "string", - "required": false - }, - "connectors_ai_openai_api_key": { - "title": "OpenAI API Key", - "description": "API key for the OpenAI AI provider.", - "type": "string", - "required": false - }, "title": { "title": "Title", "description": "Site title.", @@ -14762,9 +14744,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "connectors_ai_anthropic_api_key": "", - "connectors_ai_google_api_key": "", - "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From e396e6a6199bfc6b626f71aa54a44a54d7b6b35c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:08:19 +0100 Subject: [PATCH 4/7] Connectors: Move `wp_connectors_settings` filter after `setting_name` generation. Addresses review feedback to filter the final, fully populated value. The filter now receives connectors with `setting_name` already set for API-key connectors, and runs as the last step before returning. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 21 +++--- .../wpConnectorsGetConnectorSettings.php | 65 ++----------------- 2 files changed, 17 insertions(+), 69 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 9b25f488d14d6..281db51fdb42b 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -200,6 +200,13 @@ function _wp_connectors_get_connector_settings(): array { } } + // Add setting_name for AI provider connectors that use API key authentication. + foreach ( $connectors as $connector_id => $connector ) { + if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { + $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; + } + } + ksort( $connectors ); /** @@ -207,8 +214,8 @@ function _wp_connectors_get_connector_settings(): array { * * Allows plugins to add, modify, or remove connectors displayed * on the Connectors screen. Runs after built-in and AI Client - * registry providers are collected, but before `setting_name` is - * generated for API-key connectors. + * registry providers are collected and fully populated with + * `setting_name` for API-key connectors. * * @since 7.0.0 * @@ -226,23 +233,17 @@ function _wp_connectors_get_connector_settings(): array { * } * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url. When 'none', only method is present. + * credentials_url and setting_name. When 'none', only method is present. * * @type string $method The authentication method: 'api_key' or 'none'. * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. Present when method is 'api_key'. * } * } * } */ $connectors = apply_filters( 'wp_connectors_settings', $connectors ); - // Add setting_name for AI provider connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; - } - } - return $connectors; } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index e2bc42c2ebdeb..54193acb10762 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -178,65 +178,7 @@ public function test_filter_can_remove_connector() { /** * @ticket 64791 */ - public function test_filter_added_api_key_connector_gets_setting_name() { - $callback = static function ( $connectors ) { - $connectors['custom_ai'] = array( - 'name' => 'Custom AI', - 'description' => 'A custom AI provider.', - 'type' => 'ai_provider', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/keys', - ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'custom_ai', $connectors ); - $this->assertSame( - 'connectors_ai_custom_ai_api_key', - $connectors['custom_ai']['authentication']['setting_name'], - 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' - ); - } - - /** - * @ticket 64791 - */ - public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { - $callback = static function ( $connectors ) { - $connectors['my_crm'] = array( - 'name' => 'My CRM', - 'description' => 'CRM integration.', - 'type' => 'crm', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/crm-keys', - ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'my_crm', $connectors ); - $this->assertArrayNotHasKey( - 'setting_name', - $connectors['my_crm']['authentication'], - 'Non-AI connectors should not receive an auto-generated setting_name.' - ); - } - - /** - * @ticket 64791 - */ - public function test_filter_receives_all_default_connectors() { + public function test_filter_receives_all_default_connectors_with_setting_name() { $received = null; $callback = static function ( $connectors ) use ( &$received ) { @@ -252,5 +194,10 @@ public function test_filter_receives_all_default_connectors() { $this->assertArrayHasKey( 'openai', $received ); $this->assertArrayHasKey( 'anthropic', $received ); $this->assertArrayHasKey( 'mock_connectors_test', $received ); + + // The filter receives fully populated data, including setting_name for API-key connectors. + $this->assertSame( 'connectors_ai_openai_api_key', $received['openai']['authentication']['setting_name'] ); + $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); + $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); } } From 3b5292fb7e722e0cc846acbc764feb4593d250dc Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:09:28 +0100 Subject: [PATCH 5/7] Connectors: Add test for filter returning empty array. Co-Authored-By: Claude Opus 4.6 --- .../wpConnectorsGetConnectorSettings.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 54193acb10762..506bbc04a1e9a 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -200,4 +200,19 @@ public function test_filter_receives_all_default_connectors_with_setting_name() $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); } + + /** + * @ticket 64791 + */ + public function test_filter_can_return_empty_array() { + $callback = static function () { + return array(); + }; + add_filter( 'wp_connectors_settings', $callback ); + + $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); + + $this->assertSame( array(), $connectors ); + } } From 292436b194b209d7c7e5c2be17f8417461fc043a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:39:42 +0100 Subject: [PATCH 6/7] Connectors: Replace filter with WP_Connector_Registry and action-based registration. Introduces `WP_Connector_Registry` class with `register()`, `unregister()`, `is_registered()`, `get_registered()`, and `get_all_registered()` methods. Adds public API functions: `wp_register_connector()`, `wp_unregister_connector()`, `wp_has_connector()`, `wp_get_connector()`, `wp_get_connectors()`. Connectors must be registered on the `wp_connectors_init` action hook. Default connectors (Anthropic, Google, OpenAI) and AI Client registry providers are registered via `_wp_register_default_connectors()`. Removes the `wp_connectors_settings` filter in favor of the registry pattern, consistent with `WP_Abilities_Registry` and other Core registration APIs. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 298 ++++++++++++++ src/wp-includes/connectors.php | 377 ++++++++++++------ src/wp-includes/default-filters.php | 3 + src/wp-settings.php | 1 + .../wp-ai-client-mock-provider-trait.php | 23 +- .../tests/connectors/wpConnectorRegistry.php | 340 ++++++++++++++++ .../wpConnectorsGetConnectorSettings.php | 100 +---- .../tests/connectors/wpRegisterConnector.php | 157 ++++++++ 8 files changed, 1070 insertions(+), 229 deletions(-) create mode 100644 src/wp-includes/class-wp-connector-registry.php create mode 100644 tests/phpunit/tests/connectors/wpConnectorRegistry.php create mode 100644 tests/phpunit/tests/connectors/wpRegisterConnector.php diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php new file mode 100644 index 0000000000000..5f5e0ed93ea62 --- /dev/null +++ b/src/wp-includes/class-wp-connector-registry.php @@ -0,0 +1,298 @@ +is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( 'api_key' === $args['authentication']['method'] ) { + $connector['authentication']['credentials_url'] = isset( $args['authentication']['credentials_url'] ) ? $args['authentication']['credentials_url'] : null; + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * Do not use this method directly. Instead, use the `wp_unregister_connector()` function. + * + * @since 7.0.0 + * + * @see wp_unregister_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. + * + * @since 7.0.0 + * + * @see wp_get_connectors() + * + * @return array[] The array of registered connectors keyed by connector ID. + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `wp_has_connector()` function. + * + * @since 7.0.0 + * + * @see wp_has_connector() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `wp_get_connector()` function. + * + * @since 7.0.0 + * + * @see wp_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: init action. */ + __( 'Connector registry should not be initialized before the %s action has fired.' ), + 'init' + ), + '7.0.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing connector registry. + * + * Connectors should be registered on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $instance Connector registry object. + */ + do_action( 'wp_connectors_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is unserialized. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is serialized. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized.' ); + } +} diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 281db51fdb42b..9caf8954b89da 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -10,6 +10,245 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +/** + * Registers a new connector. + * + * Must be called during the `wp_connectors_init` action. + * + * Example: + * + * function my_plugin_register_connectors( WP_Connector_Registry $registry ): void { + * wp_register_connector( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } + * add_action( 'wp_connectors_init', 'my_plugin_register_connectors' ); + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::register() + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. + */ +function wp_register_connector( string $id, array $args ): ?array { + if ( ! doing_action( 'wp_connectors_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: wp_connectors_init, 2: string value of the connector ID. */ + __( 'Connectors must be registered on the %1$s action. The connector %2$s was not registered.' ), + 'wp_connectors_init', + '' . esc_html( $id ) . '' + ), + '7.0.0' + ); + return null; + } + + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $id, $args ); +} + +/** + * Unregisters a connector. + * + * Can be called at any time after the connector has been registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::unregister() + * @see wp_register_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + */ +function wp_unregister_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $id ); +} + +/** + * Checks if a connector is registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ +function wp_has_connector( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $id ); +} + +/** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ +function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); +} + +/** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ +function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Registers default connectors from Core and the AI Client registry. + * + * @since 7.0.0 + * @access private + */ +function _wp_register_default_connectors(): void { + // Built-in connectors. + $defaults = array( + 'anthropic' => array( + 'name' => 'Anthropic', + 'description' => __( 'Text generation with Claude.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-anthropic', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.claude.com/settings/keys', + ), + ), + 'google' => array( + 'name' => 'Google', + 'description' => __( 'Text and image generation with Gemini and Imagen.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-google', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://aistudio.google.com/api-keys', + ), + ), + 'openai' => array( + 'name' => 'OpenAI', + 'description' => __( 'Text and image generation with GPT and Dall-E.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-openai', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.openai.com/api-keys', + ), + ), + ); + + // Register built-in connectors first. + foreach ( $defaults as $id => $args ) { + wp_register_connector( $id, $args ); + } + + // Register connectors from the AI Client registry. + $ai_registry = AiClient::defaultRegistry(); + + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); + $provider_metadata = $provider_class_name::metadata(); + + $auth_method = $provider_metadata->getAuthenticationMethod(); + $is_api_key = null !== $auth_method && $auth_method->isApiKey(); + + if ( $is_api_key ) { + $credentials_url = $provider_metadata->getCredentialsUrl(); + $authentication = array( + 'method' => 'api_key', + 'credentials_url' => $credentials_url ? $credentials_url : null, + ); + } else { + $authentication = array( 'method' => 'none' ); + } + + $name = $provider_metadata->getName(); + $description = $provider_metadata->getDescription(); + + if ( wp_has_connector( $connector_id ) ) { + // Already registered as a built-in; skip to avoid duplicate registration error. + continue; + } + + wp_register_connector( + $connector_id, + array( + 'name' => $name ? $name : ucwords( $connector_id ), + 'description' => $description ? $description : '', + 'type' => 'ai_provider', + 'authentication' => $authentication, + ) + ); + } +} /** * Masks an API key, showing only the last 4 characters. @@ -116,134 +355,8 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * } */ function _wp_connectors_get_connector_settings(): array { - $connectors = array( - 'anthropic' => array( - 'name' => 'Anthropic', - 'description' => __( 'Text generation with Claude.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-anthropic', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.claude.com/settings/keys', - ), - ), - 'google' => array( - 'name' => 'Google', - 'description' => __( 'Text and image generation with Gemini and Imagen.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-google', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://aistudio.google.com/api-keys', - ), - ), - 'openai' => array( - 'name' => 'OpenAI', - 'description' => __( 'Text and image generation with GPT and Dall-E.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-openai', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.openai.com/api-keys', - ), - ), - ); - - $registry = AiClient::defaultRegistry(); - - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class_name = $registry->getProviderClassName( $connector_id ); - $provider_metadata = $provider_class_name::metadata(); - - $auth_method = $provider_metadata->getAuthenticationMethod(); - $is_api_key = null !== $auth_method && $auth_method->isApiKey(); - - if ( $is_api_key ) { - $credentials_url = $provider_metadata->getCredentialsUrl(); - $authentication = array( - 'method' => 'api_key', - 'credentials_url' => $credentials_url ? $credentials_url : null, - ); - } else { - $authentication = array( 'method' => 'none' ); - } - - $name = $provider_metadata->getName(); - $description = $provider_metadata->getDescription(); - - if ( isset( $connectors[ $connector_id ] ) ) { - // Override fields with non-empty registry values. - if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; - } - if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; - } - // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; - if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; - } - } else { - $connectors[ $connector_id ] = array( - 'name' => $name ? $name : ucwords( $connector_id ), - 'description' => $description ? $description : '', - 'type' => 'ai_provider', - 'authentication' => $authentication, - ); - } - } - - // Add setting_name for AI provider connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; - } - } - + $connectors = wp_get_connectors(); ksort( $connectors ); - - /** - * Filters the registered connector settings. - * - * Allows plugins to add, modify, or remove connectors displayed - * on the Connectors screen. Runs after built-in and AI Client - * registry providers are collected and fully populated with - * `setting_name` for API-key connectors. - * - * @since 7.0.0 - * - * @param array $connectors { - * Connector settings keyed by connector ID. - * - * @type array ...$0 { - * Data for a single connector. - * - * @type string $name The connector's display name. - * @type string $description The connector's description. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. - * } - * @type array $authentication { - * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. - * - * @type string $method The authentication method: 'api_key' or 'none'. - * @type string|null $credentials_url Optional. URL where users can obtain API credentials. - * @type string $setting_name Optional. The setting name for the API key. Present when method is 'api_key'. - * } - * } - * } - */ - $connectors = apply_filters( 'wp_connectors_settings', $connectors ); - return $connectors; } @@ -317,7 +430,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -326,7 +439,7 @@ function _wp_register_default_connector_settings(): void { } // Skip registering the setting if the provider is not in the registry. - if ( ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -372,7 +485,7 @@ function _wp_register_default_connector_settings(): void { */ function _wp_connectors_pass_default_keys_to_ai_client(): void { try { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; @@ -383,7 +496,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - if ( ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -392,7 +505,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $registry->setProviderRequestAuthentication( + $ai_registry->setProviderRequestAuthentication( $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 0bcd2d6b15acb..a6a6fb7d3964e 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -539,6 +539,9 @@ add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); +// Connectors API. +add_action( 'wp_connectors_init', '_wp_register_default_connectors' ); + // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..dab1d8fd4c0de 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -294,6 +294,7 @@ require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; +require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php index 9797017451e0d..e7637bf239119 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait { * Must be called from set_up_before_class() after parent::set_up_before_class(). */ private static function register_mock_connectors_provider(): void { - $registry = AiClient::defaultRegistry(); - if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) { - $registry->registerProvider( Mock_Connectors_Test_Provider::class ); + $ai_registry = AiClient::defaultRegistry(); + if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) { + $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class ); + } + + // Also register in the WP connector registry if not already present. + $connector_registry = WP_Connector_Registry::get_instance(); + if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) { + $connector_registry->register( + 'mock_connectors_test', + array( + 'name' => 'Mock Connectors Test', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => null, + ), + ) + ); } } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php new file mode 100644 index 0000000000000..2d82d3fdf9604 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -0,0 +1,340 @@ +registry = new WP_Connector_Registry(); + + self::$default_args = array( + 'name' => 'Test Provider', + 'description' => 'A test AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * @ticket 64791 + */ + public function test_register_returns_connector_data() { + $result = $this->registry->register( 'test_provider', self::$default_args ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'A test AI provider.', $result['description'] ); + $this->assertSame( 'ai_provider', $result['type'] ); + $this->assertSame( 'api_key', $result['authentication']['method'] ); + $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] ); + $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_generates_setting_name_for_api_key() { + $result = $this->registry->register( 'my_ai', self::$default_args ); + + $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_no_setting_name_for_none_auth() { + $args = array( + 'name' => 'No Auth Provider', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_defaults_description_to_empty_string() { + $args = array( + 'name' => 'Minimal', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + + $result = $this->registry->register( 'minimal', $args ); + + $this->assertSame( '', $result['description'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_includes_plugin_data() { + $args = self::$default_args; + $args['plugin'] = array( 'slug' => 'my-plugin' ); + + $result = $this->registry->register( 'with_plugin', $args ); + + $this->assertArrayHasKey( 'plugin', $result ); + $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_plugin_when_not_provided() { + $result = $this->registry->register( 'no_plugin', self::$default_args ); + + $this->assertArrayNotHasKey( 'plugin', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_uppercase() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'InvalidId', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_dashes() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'my-provider', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( '', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_duplicate_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $this->registry->register( 'duplicate', self::$default_args ); + $result = $this->registry->register( 'duplicate', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['name'] ); + + $result = $this->registry->register( 'no_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['name'] = ''; + + $result = $this->registry->register( 'empty_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_type() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['type'] ); + + $result = $this->registry->register( 'no_type', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_authentication() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['authentication'] ); + + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_auth_method() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['method'] = 'oauth'; + + $result = $this->registry->register( 'bad_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_true_for_registered() { + $this->registry->register( 'exists', self::$default_args ); + + $this->assertTrue( $this->registry->is_registered( 'exists' ) ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_false_for_unregistered() { + $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_connector_data() { + $this->registry->register( 'my_connector', self::$default_args ); + + $result = $this->registry->get_registered( 'my_connector' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = $this->registry->get_registered( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_all_connectors() { + $this->registry->register( 'first', self::$default_args ); + + $args = self::$default_args; + $args['name'] = 'Second Provider'; + $this->registry->register( 'second', $args ); + + $all = $this->registry->get_all_registered(); + + $this->assertCount( 2, $all ); + $this->assertArrayHasKey( 'first', $all ); + $this->assertArrayHasKey( 'second', $all ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_empty_when_none() { + $this->assertSame( array(), $this->registry->get_all_registered() ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->registry->register( 'to_remove', self::$default_args ); + + $result = $this->registry->unregister( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertFalse( $this->registry->is_registered( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = $this->registry->unregister( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_registry() { + $instance = WP_Connector_Registry::get_instance(); + + $this->assertInstanceOf( WP_Connector_Registry::class, $instance ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_same_instance() { + $instance1 = WP_Connector_Registry::get_instance(); + $instance2 = WP_Connector_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 506bbc04a1e9a..2a7ce199fa777 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -117,102 +117,14 @@ public function test_includes_registered_provider_from_registry() { } /** - * @ticket 64791 - */ - public function test_filter_can_add_new_connector() { - $callback = static function ( $connectors ) { - $connectors['my_email_service'] = array( - 'name' => 'My Email Service', - 'description' => 'Send transactional emails.', - 'type' => 'email_service', - 'authentication' => array( 'method' => 'none' ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'my_email_service', $connectors ); - $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); - $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); - $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); - } - - /** - * @ticket 64791 - */ - public function test_filter_can_modify_existing_connector() { - $callback = static function ( $connectors ) { - $connectors['google']['description'] = 'Custom description for Google.'; - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); - } - - /** - * @ticket 64791 - */ - public function test_filter_can_remove_connector() { - $callback = static function ( $connectors ) { - unset( $connectors['openai'] ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayNotHasKey( 'openai', $connectors ); - // Other connectors remain. - $this->assertArrayHasKey( 'google', $connectors ); - $this->assertArrayHasKey( 'anthropic', $connectors ); - } - - /** - * @ticket 64791 - */ - public function test_filter_receives_all_default_connectors_with_setting_name() { - $received = null; - - $callback = static function ( $connectors ) use ( &$received ) { - $received = $connectors; - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'google', $received ); - $this->assertArrayHasKey( 'openai', $received ); - $this->assertArrayHasKey( 'anthropic', $received ); - $this->assertArrayHasKey( 'mock_connectors_test', $received ); - - // The filter receives fully populated data, including setting_name for API-key connectors. - $this->assertSame( 'connectors_ai_openai_api_key', $received['openai']['authentication']['setting_name'] ); - $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); - $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); - } - - /** - * @ticket 64791 + * @ticket 64730 */ - public function test_filter_can_return_empty_array() { - $callback = static function () { - return array(); - }; - add_filter( 'wp_connectors_settings', $callback ); - + public function test_connectors_are_sorted_alphabetically() { $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); + $keys = array_keys( $connectors ); + $sorted = $keys; + sort( $sorted ); - $this->assertSame( array(), $connectors ); + $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' ); } } diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php new file mode 100644 index 0000000000000..1e05a68a58609 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -0,0 +1,157 @@ + 'Test Connector', + 'description' => 'A test connector.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * Helper to simulate the wp_connectors_init action for registration. + * + * @param callable $callback The registration callback to run. + */ + private function simulate_doing_wp_connectors_init_action( callable $callback ): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_connectors_init'; + $callback(); + array_pop( $wp_current_filter ); + } + + /** + * @ticket 64791 + */ + public function test_register_fails_outside_action() { + $this->setExpectedIncorrectUsage( 'wp_register_connector' ); + + $result = wp_register_connector( 'outside_action', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_succeeds_during_action() { + $result = null; + + $this->simulate_doing_wp_connectors_init_action( + function () use ( &$result ) { + $result = wp_register_connector( 'during_action', self::$default_args ); + } + ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Connector', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_true_for_default() { + // Default connectors are registered via wp_connectors_init. + $this->assertTrue( wp_has_connector( 'openai' ) ); + $this->assertTrue( wp_has_connector( 'google' ) ); + $this->assertTrue( wp_has_connector( 'anthropic' ) ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_false_for_unregistered() { + $this->assertFalse( wp_has_connector( 'nonexistent_provider' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_data_for_default() { + $connector = wp_get_connector( 'openai' ); + + $this->assertIsArray( $connector ); + $this->assertSame( 'OpenAI', $connector['name'] ); + $this->assertSame( 'ai_provider', $connector['type'] ); + $this->assertSame( 'api_key', $connector['authentication']['method'] ); + $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = wp_get_connector( 'nonexistent_provider' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_connectors_returns_all_defaults() { + $connectors = wp_get_connectors(); + + $this->assertArrayHasKey( 'openai', $connectors ); + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->simulate_doing_wp_connectors_init_action( + function () { + wp_register_connector( 'to_remove', self::$default_args ); + } + ); + + $this->assertTrue( wp_has_connector( 'to_remove' ) ); + + $result = wp_unregister_connector( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertFalse( wp_has_connector( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_nonexistent() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = wp_unregister_connector( 'nonexistent' ); + + $this->assertNull( $result ); + } +} From 951bb8c9a1fa6e3f613cbac790421f450267189a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:55:06 +0100 Subject: [PATCH 7/7] Connectors: Preserve AI Client registry data precedence over hardcoded defaults. Merges AI Client registry values on top of hardcoded defaults before registering, so provider plugin data takes precedence while hardcoded values serve as fallbacks. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 40 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 9caf8954b89da..fb05b13d044be 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -205,12 +205,8 @@ function _wp_register_default_connectors(): void { ), ); - // Register built-in connectors first. - foreach ( $defaults as $id => $args ) { - wp_register_connector( $id, $args ); - } - - // Register connectors from the AI Client registry. + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. $ai_registry = AiClient::defaultRegistry(); foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { @@ -233,20 +229,32 @@ function _wp_register_default_connectors(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); - if ( wp_has_connector( $connector_id ) ) { - // Already registered as a built-in; skip to avoid duplicate registration error. - continue; - } - - wp_register_connector( - $connector_id, - array( + if ( isset( $defaults[ $connector_id ] ) ) { + // Override fields with non-empty registry values. + if ( $name ) { + $defaults[ $connector_id ]['name'] = $name; + } + if ( $description ) { + $defaults[ $connector_id ]['description'] = $description; + } + // Always update auth method; keep existing credentials_url as fallback. + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; + if ( ! empty( $authentication['credentials_url'] ) ) { + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + } + } else { + $defaults[ $connector_id ] = array( 'name' => $name ? $name : ucwords( $connector_id ), 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, - ) - ); + ); + } + } + + // Register all connectors. + foreach ( $defaults as $id => $args ) { + wp_register_connector( $id, $args ); } }