From a3afdfea73ae0a332984bcfcf1ef27112a2480ad Mon Sep 17 00:00:00 2001 From: Abhishek Deshpande Date: Thu, 5 Mar 2026 03:37:33 +0530 Subject: [PATCH 1/3] Add multisite network checks with acceptance coverage --- .../check-network-required-plugins.feature | 55 ++++++++++++ features/check-network-site-count.feature | 46 ++++++++++ .../check-network-site-option-value.feature | 77 ++++++++++++++++ src/Check/Network_Required_Plugins.php | 89 +++++++++++++++++++ src/Check/Network_Site_Count.php | 57 ++++++++++++ src/Check/Network_Site_Option_Value.php | 73 +++++++++++++++ src/Command.php | 4 +- 7 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 features/check-network-required-plugins.feature create mode 100644 features/check-network-site-count.feature create mode 100644 features/check-network-site-option-value.feature create mode 100644 src/Check/Network_Required_Plugins.php create mode 100644 src/Check/Network_Site_Count.php create mode 100644 src/Check/Network_Site_Option_Value.php diff --git a/features/check-network-required-plugins.feature b/features/check-network-required-plugins.feature new file mode 100644 index 0000000..f2dc83f --- /dev/null +++ b/features/check-network-required-plugins.feature @@ -0,0 +1,55 @@ +Feature: Check required network plugins + + Scenario: Verify check description + Given an empty directory + And a config.yml file: + """ + network-required-plugins: + check: Network_Required_Plugins + options: + plugins: + - akismet + """ + + When I try `wp doctor list --fields=name,description --config=config.yml` + Then STDOUT should be a table containing rows: + | name | description | + | network-required-plugins | Errors when required plugins are not network-activated. | + + Scenario: Required plugin is not network-activated + Given a WP multisite installation + And a config.yml file: + """ + network-required-plugins: + check: Network_Required_Plugins + options: + plugins: + - akismet + """ + + When I try `wp doctor check network-required-plugins --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-required-plugins | error | Required network plugin check failed. Not network-activated: akismet (inactive). | + And STDERR should contain: + """ + Error: 1 check reports 'error'. + """ + And the return code should be 1 + + Scenario: Required plugin is network-activated + Given a WP multisite installation + And a config.yml file: + """ + network-required-plugins: + check: Network_Required_Plugins + options: + plugins: + - akismet + """ + And I run `wp plugin activate akismet --network` + + When I run `wp doctor check network-required-plugins --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-required-plugins | success | All required plugins are network-activated. | diff --git a/features/check-network-site-count.feature b/features/check-network-site-count.feature new file mode 100644 index 0000000..6e466c2 --- /dev/null +++ b/features/check-network-site-count.feature @@ -0,0 +1,46 @@ +Feature: Check multisite network site count + + Scenario: Verify check description + Given an empty directory + And a config.yml file: + """ + network-site-count: + check: Network_Site_Count + """ + + When I try `wp doctor list --fields=name,description --config=config.yml` + Then STDOUT should be a table containing rows: + | name | description | + | network-site-count | Warns when multisite network site count is outside the range 1-500. | + + Scenario: Site count is within expected range + Given a WP multisite installation + And a config.yml file: + """ + network-site-count: + check: Network_Site_Count + options: + minimum: 1 + maximum: 10 + """ + + When I run `wp doctor check network-site-count --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-site-count | success | Network has 1 sites; expected between 1 and 10. | + + Scenario: Site count is outside expected range + Given a WP multisite installation + And a config.yml file: + """ + network-site-count: + check: Network_Site_Count + options: + minimum: 2 + maximum: 10 + """ + + When I run `wp doctor check network-site-count --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-site-count | warning | Network has 1 sites; expected between 2 and 10. | diff --git a/features/check-network-site-option-value.feature b/features/check-network-site-option-value.feature new file mode 100644 index 0000000..b40899d --- /dev/null +++ b/features/check-network-site-option-value.feature @@ -0,0 +1,77 @@ +Feature: Check the value of a network option + + Scenario: Verify check description + Given an empty directory + And a config.yml file: + """ + network-registration: + check: Network_Site_Option_Value + options: + option: registration + value: none + """ + + When I try `wp doctor list --fields=name,description --config=config.yml` + Then STDOUT should be a table containing rows: + | name | description | + | network-registration | Confirms the expected value of the network option 'registration'. | + + Scenario: Check the value of a network option + Given a WP multisite installation + And a config.yml file: + """ + network-registration: + check: Network_Site_Option_Value + options: + option: registration + value: all + """ + And I run `wp eval 'update_site_option( "registration", "none" );'` + + When I try `wp doctor check network-registration --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-registration | error | Network option 'registration' is 'none' but expected to be 'all'. | + And STDERR should contain: + """ + Error: 1 check reports 'error'. + """ + And the return code should be 1 + + When I run `wp eval 'update_site_option( "registration", "all" );'` + Then STDOUT should be empty + + When I run `wp doctor check network-registration --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-registration | success | Network option 'registration' is 'all' as expected. | + + Scenario: Check value_is_not for a network option + Given a WP multisite installation + And a config.yml file: + """ + network-registration: + check: Network_Site_Option_Value + options: + option: registration + value_is_not: none + """ + And I run `wp eval 'update_site_option( "registration", "none" );'` + + When I try `wp doctor check network-registration --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-registration | error | Network option 'registration' is 'none' and expected not to be. | + And STDERR should contain: + """ + Error: 1 check reports 'error'. + """ + And the return code should be 1 + + When I run `wp eval 'update_site_option( "registration", "all" );'` + Then STDOUT should be empty + + When I run `wp doctor check network-registration --config=config.yml` + Then STDOUT should be a table containing rows: + | name | status | message | + | network-registration | success | Network option 'registration' is not 'none' as expected. | diff --git a/src/Check/Network_Required_Plugins.php b/src/Check/Network_Required_Plugins.php new file mode 100644 index 0000000..f1fb673 --- /dev/null +++ b/src/Check/Network_Required_Plugins.php @@ -0,0 +1,89 @@ +plugins ) ) { + $this->plugins = explode( ',', $this->plugins ); + } + if ( ! is_array( $this->plugins ) ) { + WP_CLI::error( 'Invalid plugins option. Provide an array or comma-delimited string.' ); + } + $this->plugins = array_values( array_filter( array_map( 'trim', $this->plugins ) ) ); + if ( empty( $this->plugins ) ) { + WP_CLI::error( 'At least one plugin slug is required.' ); + } + } + + public function run() { + if ( ! is_multisite() ) { + $this->set_status( 'success' ); + $this->set_message( 'WordPress is not a multisite installation; required network plugin check skipped.' ); + return; + } + + $plugins = array(); + ob_start(); + WP_CLI::run_command( array( 'plugin', 'list' ), array( 'format' => 'json' ) ); + $ret = ob_get_clean(); + if ( ! empty( $ret ) ) { + $plugins = json_decode( $ret, true ); + } + + if ( ! is_array( $plugins ) ) { + $this->set_status( 'error' ); + $this->set_message( 'Unable to parse plugin list output.' ); + return; + } + + $plugin_statuses = array(); + foreach ( $plugins as $plugin ) { + $plugin_statuses[ $plugin['name'] ] = $plugin['status']; + } + + $missing = array(); + $not_network_active = array(); + foreach ( $this->plugins as $plugin_name ) { + if ( ! isset( $plugin_statuses[ $plugin_name ] ) ) { + $missing[] = $plugin_name; + continue; + } + if ( 'active-network' !== $plugin_statuses[ $plugin_name ] ) { + $not_network_active[] = "{$plugin_name} ({$plugin_statuses[$plugin_name]})"; + } + } + + if ( empty( $missing ) && empty( $not_network_active ) ) { + $this->set_status( 'success' ); + $this->set_message( 'All required plugins are network-activated.' ); + return; + } + + $parts = array(); + if ( ! empty( $missing ) ) { + $parts[] = 'Missing plugins: ' . implode( ', ', $missing ) . '.'; + } + if ( ! empty( $not_network_active ) ) { + $parts[] = 'Not network-activated: ' . implode( ', ', $not_network_active ) . '.'; + } + $this->set_status( 'error' ); + $this->set_message( 'Required network plugin check failed. ' . implode( ' ', $parts ) ); + } +} diff --git a/src/Check/Network_Site_Count.php b/src/Check/Network_Site_Count.php new file mode 100644 index 0000000..ce7464f --- /dev/null +++ b/src/Check/Network_Site_Count.php @@ -0,0 +1,57 @@ +minimum; + $maximum = (int) $this->maximum; + if ( $minimum < 0 || $maximum < 0 || $maximum < $minimum ) { + WP_CLI::error( 'Invalid thresholds. Ensure 0 <= minimum <= maximum.' ); + } + } + + public function run() { + if ( ! is_multisite() ) { + $this->set_status( 'success' ); + $this->set_message( 'WordPress is not a multisite installation; network site count check skipped.' ); + return; + } + + $count = (int) get_sites( array( 'count' => true ) ); + $minimum = (int) $this->minimum; + $maximum = (int) $this->maximum; + + if ( $count < $minimum || $count > $maximum ) { + $this->set_status( 'warning' ); + $this->set_message( "Network has {$count} sites; expected between {$minimum} and {$maximum}." ); + return; + } + + $this->set_status( 'success' ); + $this->set_message( "Network has {$count} sites; expected between {$minimum} and {$maximum}." ); + } +} diff --git a/src/Check/Network_Site_Option_Value.php b/src/Check/Network_Site_Option_Value.php new file mode 100644 index 0000000..7e6add8 --- /dev/null +++ b/src/Check/Network_Site_Option_Value.php @@ -0,0 +1,73 @@ +set_status( 'success' ); + $this->set_message( 'WordPress is not a multisite installation; network option check skipped.' ); + return; + } + + if ( isset( $this->value ) && isset( $this->value_is_not ) ) { + $this->set_status( 'error' ); + $this->set_message( 'You must use either "value" or "value_is_not".' ); + return; + } + + if ( ! isset( $this->value ) && ! isset( $this->value_is_not ) ) { + $this->set_status( 'error' ); + $this->set_message( 'You must specify "value" or "value_is_not".' ); + return; + } + + $actual_value = get_site_option( $this->option ); + + if ( isset( $this->value ) ) { + if ( $actual_value == $this->value ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Keep existing behavior. + $this->set_status( 'success' ); + $this->set_message( "Network option '{$this->option}' is '{$this->value}' as expected." ); + } else { + $this->set_status( 'error' ); + $this->set_message( "Network option '{$this->option}' is '{$actual_value}' but expected to be '{$this->value}'." ); + } + return; + } + + if ( $actual_value == $this->value_is_not ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Keep existing behavior. + $this->set_status( 'error' ); + $this->set_message( "Network option '{$this->option}' is '{$actual_value}' and expected not to be." ); + } else { + $this->set_status( 'success' ); + $this->set_message( "Network option '{$this->option}' is not '{$this->value_is_not}' as expected." ); + } + } +} diff --git a/src/Command.php b/src/Command.php index 9c76b7f..aeea080 100644 --- a/src/Command.php +++ b/src/Command.php @@ -337,7 +337,9 @@ public function list_( $args, $assoc_args ) { if ( '_' === $prop_name[0] ) { continue; } - $prop->setAccessible( true ); + if ( PHP_VERSION_ID < 80100 ) { + $prop->setAccessible( true ); + } $value = $prop->getValue( $class ); if ( is_array( $value ) ) { $value = json_encode( $value ); From f220102964efc8f8080242f2e53462e7d6a013e0 Mon Sep 17 00:00:00 2001 From: Abhishek Deshpande Date: Thu, 5 Mar 2026 04:02:23 +0530 Subject: [PATCH 2/3] Refactor multisite checks to use WP plugin APIs --- src/Check/Network_Required_Plugins.php | 61 +++++++++++++++++--------- src/Check/Network_Site_Count.php | 16 +++---- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/Check/Network_Required_Plugins.php b/src/Check/Network_Required_Plugins.php index f1fb673..970bb90 100644 --- a/src/Check/Network_Required_Plugins.php +++ b/src/Check/Network_Required_Plugins.php @@ -39,35 +39,27 @@ public function run() { return; } - $plugins = array(); - ob_start(); - WP_CLI::run_command( array( 'plugin', 'list' ), array( 'format' => 'json' ) ); - $ret = ob_get_clean(); - if ( ! empty( $ret ) ) { - $plugins = json_decode( $ret, true ); + if ( ! function_exists( 'get_plugins' ) || ! function_exists( 'is_plugin_active' ) || ! function_exists( 'is_plugin_active_for_network' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - if ( ! is_array( $plugins ) ) { - $this->set_status( 'error' ); - $this->set_message( 'Unable to parse plugin list output.' ); - return; - } - - $plugin_statuses = array(); - foreach ( $plugins as $plugin ) { - $plugin_statuses[ $plugin['name'] ] = $plugin['status']; - } + $installed_plugins = get_plugins(); $missing = array(); $not_network_active = array(); - foreach ( $this->plugins as $plugin_name ) { - if ( ! isset( $plugin_statuses[ $plugin_name ] ) ) { - $missing[] = $plugin_name; + foreach ( $this->plugins as $plugin_slug ) { + $plugin_file = $this->get_plugin_file( $plugin_slug, $installed_plugins ); + if ( null === $plugin_file ) { + $missing[] = $plugin_slug; continue; } - if ( 'active-network' !== $plugin_statuses[ $plugin_name ] ) { - $not_network_active[] = "{$plugin_name} ({$plugin_statuses[$plugin_name]})"; + + if ( is_plugin_active_for_network( $plugin_file ) ) { + continue; } + + $status = is_plugin_active( $plugin_file ) ? 'active' : 'inactive'; + $not_network_active[] = "{$plugin_slug} ({$status})"; } if ( empty( $missing ) && empty( $not_network_active ) ) { @@ -86,4 +78,31 @@ public function run() { $this->set_status( 'error' ); $this->set_message( 'Required network plugin check failed. ' . implode( ' ', $parts ) ); } + + /** + * Resolve a plugin slug or file to an installed plugin file path. + * + * @param string $plugin_slug Requested plugin slug/file. + * @param array $installed_plugins Installed plugins keyed by plugin file. + * @return string|null + */ + private function get_plugin_file( $plugin_slug, $installed_plugins ) { + if ( isset( $installed_plugins[ $plugin_slug ] ) ) { + return $plugin_slug; + } + + foreach ( array_keys( $installed_plugins ) as $plugin_file ) { + $directory_slug = dirname( $plugin_file ); + if ( '.' !== $directory_slug && $directory_slug === $plugin_slug ) { + return $plugin_file; + } + + $file_slug = basename( $plugin_file, '.php' ); + if ( $file_slug === $plugin_slug ) { + return $plugin_file; + } + } + + return null; + } } diff --git a/src/Check/Network_Site_Count.php b/src/Check/Network_Site_Count.php index ce7464f..f2359ef 100644 --- a/src/Check/Network_Site_Count.php +++ b/src/Check/Network_Site_Count.php @@ -27,9 +27,9 @@ class Network_Site_Count extends Check { public function __construct( $options = array() ) { parent::__construct( $options ); - $minimum = (int) $this->minimum; - $maximum = (int) $this->maximum; - if ( $minimum < 0 || $maximum < 0 || $maximum < $minimum ) { + $this->minimum = (int) $this->minimum; + $this->maximum = (int) $this->maximum; + if ( $this->minimum < 0 || $this->maximum < 0 || $this->maximum < $this->minimum ) { WP_CLI::error( 'Invalid thresholds. Ensure 0 <= minimum <= maximum.' ); } } @@ -41,17 +41,15 @@ public function run() { return; } - $count = (int) get_sites( array( 'count' => true ) ); - $minimum = (int) $this->minimum; - $maximum = (int) $this->maximum; + $count = (int) get_sites( array( 'count' => true ) ); - if ( $count < $minimum || $count > $maximum ) { + if ( $count < $this->minimum || $count > $this->maximum ) { $this->set_status( 'warning' ); - $this->set_message( "Network has {$count} sites; expected between {$minimum} and {$maximum}." ); + $this->set_message( "Network has {$count} sites; expected between {$this->minimum} and {$this->maximum}." ); return; } $this->set_status( 'success' ); - $this->set_message( "Network has {$count} sites; expected between {$minimum} and {$maximum}." ); + $this->set_message( "Network has {$count} sites; expected between {$this->minimum} and {$this->maximum}." ); } } From 961bac6a5be48ca913182e3031559d44a4a9d1f4 Mon Sep 17 00:00:00 2001 From: Abhishek Deshpande Date: Thu, 5 Mar 2026 18:29:53 +0530 Subject: [PATCH 3/3] Handle non-scalar network option values in messages --- .../check-network-site-option-value.feature | 23 ++++++++++++ src/Check/Network_Site_Option_Value.php | 35 ++++++++++++++++--- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/features/check-network-site-option-value.feature b/features/check-network-site-option-value.feature index b40899d..d47d907 100644 --- a/features/check-network-site-option-value.feature +++ b/features/check-network-site-option-value.feature @@ -75,3 +75,26 @@ Feature: Check the value of a network option Then STDOUT should be a table containing rows: | name | status | message | | network-registration | success | Network option 'registration' is not 'none' as expected. | + + Scenario: Gracefully render array values in error messages + Given a WP multisite installation + And a config.yml file: + """ + network-registration: + check: Network_Site_Option_Value + options: + option: registration + value: none + """ + And I run `wp eval 'update_site_option( "registration", array( "users" => "all" ) );'` + + When I try `wp doctor check network-registration --config=config.yml` + Then STDOUT should contain: + """ + Network option 'registration' is '{"users":"all"}' but expected to be 'none'. + """ + And STDERR should contain: + """ + Error: 1 check reports 'error'. + """ + And the return code should be 1 diff --git a/src/Check/Network_Site_Option_Value.php b/src/Check/Network_Site_Option_Value.php index 7e6add8..3b750b7 100644 --- a/src/Check/Network_Site_Option_Value.php +++ b/src/Check/Network_Site_Option_Value.php @@ -54,20 +54,47 @@ public function run() { if ( isset( $this->value ) ) { if ( $actual_value == $this->value ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Keep existing behavior. $this->set_status( 'success' ); - $this->set_message( "Network option '{$this->option}' is '{$this->value}' as expected." ); + $this->set_message( "Network option '{$this->option}' is '" . $this->format_value_for_message( $this->value ) . "' as expected." ); } else { $this->set_status( 'error' ); - $this->set_message( "Network option '{$this->option}' is '{$actual_value}' but expected to be '{$this->value}'." ); + $this->set_message( "Network option '{$this->option}' is '" . $this->format_value_for_message( $actual_value ) . "' but expected to be '" . $this->format_value_for_message( $this->value ) . "'." ); } return; } if ( $actual_value == $this->value_is_not ) { // phpcs:ignore Universal.Operators.StrictComparisons -- Keep existing behavior. $this->set_status( 'error' ); - $this->set_message( "Network option '{$this->option}' is '{$actual_value}' and expected not to be." ); + $this->set_message( "Network option '{$this->option}' is '" . $this->format_value_for_message( $actual_value ) . "' and expected not to be." ); } else { $this->set_status( 'success' ); - $this->set_message( "Network option '{$this->option}' is not '{$this->value_is_not}' as expected." ); + $this->set_message( "Network option '{$this->option}' is not '" . $this->format_value_for_message( $this->value_is_not ) . "' as expected." ); } } + + /** + * Format arbitrary option values for stable string output. + * + * @param mixed $value Value to render. + * @return string + */ + private function format_value_for_message( $value ) { + if ( is_array( $value ) || is_object( $value ) ) { + $encoded = wp_json_encode( $value ); + if ( false !== $encoded ) { + return $encoded; + } + return 'unrepresentable value'; + } + + if ( null === $value ) { + return 'null'; + } + if ( true === $value ) { + return 'true'; + } + if ( false === $value ) { + return 'false'; + } + return (string) $value; + } }