diff --git a/assets/js/plugin-check-admin.js b/assets/js/plugin-check-admin.js index 9e79555ff..902205847 100644 --- a/assets/js/plugin-check-admin.js +++ b/assets/js/plugin-check-admin.js @@ -38,6 +38,7 @@ 'plugin-check__include-experimental' ); const useAi = document.getElementById( 'plugin-check__use-ai' ); + const useAiName = document.getElementById( 'plugin-check__use-ai-name' ); const usePcpignore = document.getElementById( 'plugin-check__use-pcpignore' ); @@ -69,6 +70,27 @@ return values; } + /** + * Reorders a list of check slugs so that priority checks run (and thus + * render) first, in the given order, followed by the remaining checks + * in their original order. + * + * @since 2.2.0 + * + * @param {Array} checks Check slugs to run. + * @param {Array} priorityChecks Check slugs that should run first, in order. + * @return {Array} Reordered check slugs. + */ + function prioritizeChecks( checks, priorityChecks ) { + const remaining = checks.filter( + ( check ) => ! priorityChecks.includes( check ) + ); + const prioritized = priorityChecks.filter( ( check ) => + checks.includes( check ) + ); + return [ ...prioritized, ...remaining ]; + } + /** * Posts FormData to the plugin's AJAX endpoint. * @@ -144,6 +166,9 @@ if ( useAi ) { useAi.disabled = true; } + if ( useAiName ) { + useAiName.disabled = true; + } if ( usePcpignore ) { usePcpignore.disabled = true; } @@ -155,6 +180,7 @@ const categories = getSelectedValues( categoriesList ); const types = getSelectedValues( typesList ); const useAiChecked = useAi && useAi.checked ? 1 : 0; + const useAiNameChecked = useAiName && useAiName.checked ? 1 : 0; const usePcpignoreChecked = usePcpignore && usePcpignore.checked ? 1 : 0; const includeExperimentalChecked = @@ -166,15 +192,19 @@ categories, includeExperimentalChecked, useAiChecked, + useAiNameChecked, usePcpignoreChecked ) .then( ( data ) => { - currentChecks = data.checks; + currentChecks = prioritizeChecks( data.checks, [ + 'ai_name', + ] ); return setUpEnvironment( plugin, currentChecks, includeExperimentalChecked, useAiChecked, + useAiNameChecked, usePcpignoreChecked ); } ) @@ -185,6 +215,7 @@ types, includeExperimentalChecked, useAiChecked, + useAiNameChecked, usePcpignoreChecked ) ) @@ -235,6 +266,9 @@ if ( useAi ) { useAi.disabled = false; } + if ( useAiName ) { + useAiName.disabled = false; + } if ( usePcpignore ) { usePcpignore.disabled = false; } @@ -653,6 +687,7 @@ * @param {Array} checks Check slugs that will run. * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} useAiNameInput Whether to enable AI name check. * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} Resolves with the response message. */ @@ -661,6 +696,7 @@ checks, includeExperimentalInput, useAiInput, + useAiNameInput, usePcpignoreInput ) { const pluginCheckData = new FormData(); @@ -674,6 +710,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-ai-name', useAiNameInput ); pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < checks.length; i++ ) { @@ -723,6 +760,7 @@ * @param {Array} categories Selected category slugs. * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} useAiNameInput Whether to enable AI name check. * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} Resolves with the response containing plugin and checks. */ @@ -731,6 +769,7 @@ categories, includeExperimentalInput, useAiInput, + useAiNameInput, usePcpignoreInput ) { const pluginCheckData = new FormData(); @@ -741,6 +780,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-ai-name', useAiNameInput ); pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < categories.length; i++ ) { @@ -766,6 +806,7 @@ * @param {Array} types Result types to include (error, warning). * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} useAiNameInput Whether to enable AI name check. * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. */ async function runChecks( @@ -774,6 +815,7 @@ types, includeExperimentalInput, useAiInput, + useAiNameInput, usePcpignoreInput ) { let isSuccessMessage = true; @@ -786,6 +828,7 @@ types, includeExperimentalInput, useAiInput, + useAiNameInput, usePcpignoreInput ); const splitResults = splitResultsByFalsePositive( results ); @@ -1032,6 +1075,7 @@ * @param {Array} types Result types to include (error, warning). * @param {number} includeExperimentalInput Whether to include experimental checks. * @param {number} useAiInput Whether to enable AI analysis. + * @param {number} useAiNameInput Whether to enable AI name check. * @param {number} usePcpignoreInput Whether to apply .pcpignore exclusions. * @return {Promise} The check results. */ @@ -1041,6 +1085,7 @@ types, includeExperimentalInput, useAiInput, + useAiNameInput, usePcpignoreInput ) { const pluginCheckData = new FormData(); @@ -1052,6 +1097,7 @@ includeExperimentalInput ); pluginCheckData.append( 'use-ai', useAiInput ); + pluginCheckData.append( 'use-ai-name', useAiNameInput ); pluginCheckData.append( 'use-pcpignore', usePcpignoreInput ); for ( let i = 0; i < types.length; i++ ) { diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index 9df5ab783..632bca0dd 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -144,6 +144,10 @@ private function get_ajax_runner() { return new WP_Error( 'invalid-runner', __( 'AJAX Runner was not initialized correctly.', 'plugin-check' ) ); } + // Register the runner so that checks relying on Plugin_Request_Utility::get_runner() + // (e.g. AI_Name_Check) can access it, even when no runtime checks are involved. + Plugin_Request_Utility::set_runner( $runner ); + return $runner; } @@ -219,12 +223,13 @@ public function clean_up_environment() { public function get_checks_to_run() { $this->check_request_validity(); - $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $categories = is_null( $categories ) ? array() : $categories; - $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); - $checks = is_null( $checks ) ? array() : $checks; - $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); - $runner = $this->get_ajax_runner(); + $categories = filter_input( INPUT_POST, 'categories', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $categories = is_null( $categories ) ? array() : $categories; + $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); + $checks = is_null( $checks ) ? array() : $checks; + $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $use_ai_name = 1 === filter_input( INPUT_POST, 'use-ai-name', FILTER_VALIDATE_INT ); + $runner = $this->get_ajax_runner(); if ( is_wp_error( $runner ) ) { wp_send_json_error( $runner, 500 ); @@ -234,6 +239,7 @@ public function get_checks_to_run() { $this->configure_runner( $runner ); $runner->set_categories( $categories ); $runner->set_use_ai( $use_ai ); + $runner->set_use_ai_name( $use_ai_name ); $checks_to_run = $runner->get_checks_to_run(); } catch ( Exception $error ) { @@ -266,26 +272,13 @@ public function run_checks() { wp_send_json_error( $runner, 500 ); } - $runner = Plugin_Request_Utility::get_runner(); - - if ( is_null( $runner ) ) { - $runner = new AJAX_Runner(); - } - - // Make sure we are using the correct runner instance. - if ( ! ( $runner instanceof AJAX_Runner ) ) { - wp_send_json_error( - new WP_Error( 'invalid-runner', __( 'AJAX Runner was not initialized correctly.', 'plugin-check' ) ), - 500 - ); - } - $checks = filter_input( INPUT_POST, 'checks', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $checks = is_null( $checks ) ? array() : $checks; $plugin = filter_input( INPUT_POST, 'plugin', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $include_experimental = 1 === filter_input( INPUT_POST, 'include-experimental', FILTER_VALIDATE_INT ); $use_ai = 1 === filter_input( INPUT_POST, 'use-ai', FILTER_VALIDATE_INT ); + $use_ai_name = 1 === filter_input( INPUT_POST, 'use-ai-name', FILTER_VALIDATE_INT ); $use_pcpignore = 1 === filter_input( INPUT_POST, 'use-pcpignore', FILTER_VALIDATE_INT ); $types = filter_input( INPUT_POST, 'types', FILTER_DEFAULT, FILTER_FORCE_ARRAY ); $types = is_null( $types ) ? array( 'error', 'warning' ) : $types; @@ -305,6 +298,7 @@ public function run_checks() { $pcpignore_warning = PCP_Ignore_Utility::get_warning(); } $runner->set_use_ai( $use_ai ); + $runner->set_use_ai_name( $use_ai_name ); $results = $runner->run(); } catch ( Exception $error ) { wp_send_json_error( diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index f88c71b6e..7678f2556 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -163,6 +163,9 @@ public function __construct( Plugin_Context $plugin_context ) { * [--ai-model=] * : AI model preference for analysis (e.g., 'openai::gpt-4o'). Requires --ai. * + * [--ai-name] + * : Enable AI-based plugin name checking. + * * ## EXAMPLES * * wp plugin check akismet @@ -208,6 +211,7 @@ public function check( $args, $assoc_args ) { 'mode' => 'new', 'ai' => false, 'ai-model' => '', + 'ai-name' => false, 'use-pcpignore' => false, ) ); @@ -280,6 +284,7 @@ static function ( $dirs ) use ( $excluded_files ) { $runner->set_slug( $options['slug'] ); $runner->set_mode( $options['mode'] ); $runner->set_use_ai( $options['ai'] ); + $runner->set_use_ai_name( $options['ai-name'] ); if ( ! empty( $options['ai-model'] ) ) { $runner->set_ai_model_preference( $options['ai-model'] ); } diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index 8f66010ac..d51604b7a 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -21,6 +21,7 @@ * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.TooManyFields) */ abstract class Abstract_Check_Runner implements Check_Runner { @@ -42,6 +43,14 @@ abstract class Abstract_Check_Runner implements Check_Runner { */ protected $use_ai = false; + /** + * Whether AI plugin name checking is enabled. + * + * @since 2.2.0 + * @var bool + */ + protected $use_ai_name = false; + /** * AI model preference for analysis. * @@ -324,6 +333,17 @@ final public function set_use_ai( $use_ai ) { $this->use_ai = (bool) $use_ai; } + /** + * Sets whether to use AI plugin name check. + * + * @since 2.2.0 + * + * @param bool $use_ai_name True to enable AI name check, false to disable. + */ + final public function set_use_ai_name( $use_ai_name ) { + $this->use_ai_name = (bool) $use_ai_name; + } + /** * Sets the AI model preference for analysis. * @@ -342,11 +362,33 @@ final public function set_ai_model_preference( $model_preference ) { * * @return bool True if AI analysis should be used, false otherwise. */ - protected function should_use_ai() { + public function should_use_ai() { // Check if explicitly set via setter (e.g., CLI flag or checkbox). return $this->use_ai; } + /** + * Determines if AI plugin name check should be used. + * + * @since 2.2.0 + * + * @return bool True if AI name check should be used, false otherwise. + */ + public function should_use_ai_name() { + return $this->use_ai_name; + } + + /** + * Gets the AI model preference for analysis. + * + * @since x.x.x + * + * @return string Model preference. + */ + final public function get_ai_model_preference() { + return $this->ai_model_preference; + } + /** * Sets categories for filtering the checks. * diff --git a/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php b/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php new file mode 100644 index 000000000..10aa17eab --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/AI_Name_Check.php @@ -0,0 +1,288 @@ +should_run_ai_check( $runner ) ) { + return; + } + + $model_preference = $this->get_model_preference( $runner ); + $plugin_data = $this->get_plugin_name_and_author( $result ); + if ( ! $plugin_data ) { + return; + } + + $analysis = $this->run_name_analysis( $model_preference, $plugin_data['name'], $plugin_data['author'] ); + $this->handle_analysis_response( $result, $analysis, $plugin_data['file'] ); + } + + /** + * Determines if the AI check should run. + * + * @since x.x.x + * + * @param mixed $runner The active runner. + * @return bool True if the check should run, false otherwise. + */ + private function should_run_ai_check( $runner ) { + if ( ! $runner ) { + return false; + } + if ( ! method_exists( $runner, 'should_use_ai_name' ) ) { + return false; + } + if ( ! $runner->should_use_ai_name() ) { + return false; + } + if ( is_wp_error( $this->check_ai_prerequisites() ) ) { + return false; + } + if ( is_wp_error( $this->check_ai_connectors() ) ) { + return false; + } + return true; + } + + /** + * Gets the selected AI model preference. + * + * @since x.x.x + * + * @param mixed $runner The active runner. + * @return string The model preference. + */ + private function get_model_preference( $runner ) { + $model = ''; + if ( $runner && method_exists( $runner, 'get_ai_model_preference' ) ) { + $model = $runner->get_ai_model_preference(); + } + if ( empty( $model ) && class_exists( Settings_Page::class ) ) { + $model = Settings_Page::get_model_preference(); + } + return $model; + } + + /** + * Gets the plugin name and author from headers. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @return array|null Name and author if found, null otherwise. + */ + private function get_plugin_name_and_author( Check_Result $result ) { + if ( ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $file = $result->plugin()->main_file(); + $header = get_plugin_data( $file ); + $name = isset( $header['Name'] ) ? $header['Name'] : ''; + $author = isset( $header['AuthorName'] ) ? $header['AuthorName'] : ''; + + if ( empty( $name ) ) { + return null; + } + + return array( + 'name' => $name, + 'author' => $author, + 'file' => $file, + ); + } + + /** + * Handles the AI analysis response and parses results. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param mixed $analysis The analysis response or WP_Error. + * @param string $plugin_main_file The plugin main file path. + */ + private function handle_analysis_response( Check_Result $result, $analysis, string $plugin_main_file ) { + if ( is_wp_error( $analysis ) ) { + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: Error message. */ + __( 'AI plugin name check failed: %s', 'plugin-check' ), + $analysis->get_error_message() + ), + 'ai_name_check_failed', + $plugin_main_file + ); + return; + } + + $parsed = $this->parse_analysis( $analysis ); + $this->process_analysis_results( $result, $parsed, $plugin_main_file ); + } + + /** + * Process analysis results and add appropriate warnings/errors. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $parsed The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function process_analysis_results( Check_Result $result, array $parsed, string $plugin_main_file ) { + if ( ! isset( $parsed['processed_data'] ) ) { + return; + } + + $data = $parsed['processed_data']; + + $this->check_disallowed_name( $result, $data, $plugin_main_file ); + $this->check_naming_issues( $result, $data, $plugin_main_file ); + $this->check_owner_issues( $result, $data, $plugin_main_file ); + $this->check_similar_plugins( $result, $parsed, $plugin_main_file ); + } + + /** + * Check if the plugin name is disallowed. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_disallowed_name( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['disallowed'] ) ) { + $msg = isset( $data['disallowed_explanation'] ) ? $data['disallowed_explanation'] : __( 'The plugin name is disallowed.', 'plugin-check' ); + $this->add_result_error_for_file( $result, $msg, 'plugin_name_disallowed', $plugin_main_file ); + } + } + + /** + * Check for possible naming issues. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_naming_issues( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['possible_naming_issues'] ) ) { + $msg = isset( $data['naming_explanation'] ) ? $data['naming_explanation'] : __( 'The plugin name has possible naming issues.', 'plugin-check' ); + $this->add_result_warning_for_file( $result, $msg, 'plugin_name_issue', $plugin_main_file ); + } + } + + /** + * Check for possible owner issues. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $data The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_owner_issues( Check_Result $result, array $data, string $plugin_main_file ) { + if ( ! empty( $data['possible_owner_issues'] ) ) { + $msg = isset( $data['owner_explanation'] ) ? $data['owner_explanation'] : __( 'The plugin name has possible trademark or ownership issues.', 'plugin-check' ); + $this->add_result_warning_for_file( $result, $msg, 'plugin_name_trademark_issue', $plugin_main_file ); + } + } + + /** + * Check for similar plugins. + * + * @since x.x.x + * + * @param Check_Result $result The check result. + * @param array $parsed The parsed analysis data. + * @param string $plugin_main_file The plugin main file path. + */ + private function check_similar_plugins( Check_Result $result, array $parsed, string $plugin_main_file ) { + if ( ! empty( $parsed['confusion_existing_plugins'] ) && is_array( $parsed['confusion_existing_plugins'] ) ) { + $plugins_list = array(); + foreach ( $parsed['confusion_existing_plugins'] as $plugin ) { + $plugins_list[] = sprintf( '%s (%s, %s active installs)', $plugin['name'], $plugin['similarity_level'], $plugin['active_installations'] ); + } + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: List of similar plugins. */ + __( 'Plugin name is similar to existing plugins: %s', 'plugin-check' ), + implode( '; ', $plugins_list ) + ), + 'plugin_name_similarity', + $plugin_main_file + ); + } + } + + /** + * Gets the description for the check. + * + * @since x.x.x + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Checks the plugin name for guideline compliance, trademark conflicts, and similarity with existing plugins using AI.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * @since x.x.x + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/', 'plugin-check' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index 48878571c..777caf966 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -106,6 +106,7 @@ private function register_default_checks() { 'menu_image_icon' => new Checks\Plugin_Repo\Menu_Image_Icon_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), + 'ai_name' => new Checks\Plugin_Repo\AI_Name_Check(), ) ); diff --git a/includes/Plugin_Main.php b/includes/Plugin_Main.php index 210034611..518c53a59 100644 --- a/includes/Plugin_Main.php +++ b/includes/Plugin_Main.php @@ -9,6 +9,7 @@ use WordPress\Plugin_Check\Admin\Admin_AJAX; use WordPress\Plugin_Check\Admin\Admin_Page; +use WordPress\Plugin_Check\Admin\Namer_Page; use WordPress\Plugin_Check\Admin\Settings_Page; use WordPress\Plugin_Check\Admin\SVN_Checker_Page; @@ -80,8 +81,7 @@ public function add_hooks() { $settings_page->add_hooks(); // Create the Plugin Check Namer tool page. - $namer_page_class = '\\WordPress\\Plugin_Check\\Admin\\Namer_Page'; - $namer_page = new $namer_page_class(); + $namer_page = new Namer_Page(); $namer_page->add_hooks(); // Create the SVN Checker page. diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 3451f8052..fd05763ec 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -16,6 +16,8 @@ * Class providing utility methods to return plugin information based on the request. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class Plugin_Request_Utility { @@ -143,6 +145,21 @@ public static function get_runner() { return null; } + /** + * Sets the Runner class for the current request. + * + * This is needed for requests (e.g. AJAX requests running only static checks) + * where the runner is not registered early via the object-cache.php drop-in, + * so that checks relying on get_runner() (such as AI_Name_Check) can access it. + * + * @since x.x.x + * + * @param Abstract_Check_Runner $runner The runner instance for the current request. + */ + public static function set_runner( Abstract_Check_Runner $runner ) { + static::$runner = $runner; + } + /** * Runs the cleanup functions and destroys the runner. * diff --git a/templates/admin-page.php b/templates/admin-page.php index 6e3702589..b4ea49df9 100644 --- a/templates/admin-page.php +++ b/templates/admin-page.php @@ -81,9 +81,12 @@
-

+

- + +

+

+

diff --git a/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php new file mode 100644 index 000000000..29be2abc0 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/AI_Name_Check_Tests.php @@ -0,0 +1,32 @@ +createMock( AJAX_Runner::class ); + $runner->method( 'should_use_ai_name' )->willReturn( false ); + + $reflection = new \ReflectionClass( Plugin_Request_Utility::class ); + $property = $reflection->getProperty( 'runner' ); + $property->setAccessible( true ); + $property->setValue( null, $runner ); + + $check = new AI_Name_Check(); + $context = new Check_Context( WP_PLUGIN_CHECK_MAIN_FILE ); + $result = new Check_Result( $context ); + + $check->run( $result ); + + $this->assertEmpty( $result->get_errors() ); + $this->assertEmpty( $result->get_warnings() ); + + $property->setValue( null, null ); + } +}